diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 14:29:10 +0000 |
commit | 2aa4a82499d4becd2284cdb482213d541b8804dd (patch) | |
tree | b80bf8bf13c3766139fbacc530efd0dd9d54394c /toolkit/components/extensions/test | |
parent | Initial commit. (diff) | |
download | firefox-upstream.tar.xz firefox-upstream.zip |
Adding upstream version 86.0.1.upstream/86.0.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions/test')
514 files changed, 78154 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/browser/.eslintrc.js b/toolkit/components/extensions/test/browser/.eslintrc.js new file mode 100644 index 0000000000..ef228570e3 --- /dev/null +++ b/toolkit/components/extensions/test/browser/.eslintrc.js @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, + + rules: { + "no-shadow": "off", + }, +}; diff --git a/toolkit/components/extensions/test/browser/browser-serviceworker.ini b/toolkit/components/extensions/test/browser/browser-serviceworker.ini new file mode 100644 index 0000000000..58e9082f7b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser-serviceworker.ini @@ -0,0 +1,9 @@ +[DEFAULT] +support-files = + head_serviceworker.js + data/** + +prefs = + extensions.backgroundServiceWorker.enabled=true + +[browser_ext_background_serviceworker.js] diff --git a/toolkit/components/extensions/test/browser/browser.ini b/toolkit/components/extensions/test/browser/browser.ini new file mode 100644 index 0000000000..0cfb5fcd89 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser.ini @@ -0,0 +1,50 @@ +[DEFAULT] +support-files = + head.js + data/** + +[browser_ext_background_serviceworker_pref_disabled.js] +[browser_ext_downloads_filters.js] +[browser_ext_downloads_referrer.js] +[browser_ext_management_themes.js] +skip-if = verify +[browser_ext_test_mock.js] +[browser_ext_themes_additional_backgrounds_alignment.js] +[browser_ext_themes_alpha_accentcolor.js] +[browser_ext_themes_arrowpanels.js] +[browser_ext_themes_autocomplete_popup.js] +[browser_ext_themes_chromeparity.js] +[browser_ext_themes_dynamic_getCurrent.js] +[browser_ext_themes_dynamic_onUpdated.js] +[browser_ext_themes_dynamic_updates.js] +[browser_ext_themes_experiment.js] +[browser_ext_themes_findbar.js] +[browser_ext_themes_getCurrent_differentExt.js] +[browser_ext_themes_highlight.js] +[browser_ext_themes_incognito.js] +[browser_ext_themes_lwtsupport.js] +[browser_ext_themes_multiple_backgrounds.js] +[browser_ext_themes_ntp_colors.js] +[browser_ext_themes_ntp_colors_perwindow.js] +[browser_ext_themes_persistence.js] +[browser_ext_themes_reset.js] +[browser_ext_themes_sanitization.js] +[browser_ext_themes_separators.js] +[browser_ext_themes_sidebars.js] +[browser_ext_themes_static_onUpdated.js] +[browser_ext_themes_tab_line.js] +[browser_ext_themes_tab_loading.js] +[browser_ext_themes_tab_selected.js] +[browser_ext_themes_tab_separators.js] +[browser_ext_themes_tab_text.js] +[browser_ext_themes_toolbar_fields_focus.js] +[browser_ext_themes_toolbar_fields.js] +[browser_ext_themes_toolbarbutton_colors.js] +[browser_ext_themes_toolbarbutton_icons.js] +[browser_ext_themes_toolbars.js] +[browser_ext_themes_theme_transition.js] +[browser_ext_themes_warnings.js] +[browser_ext_thumbnails_bg_extension.js] +support-files = !/toolkit/components/thumbnails/test/head.js +[browser_ext_webRequest_redirect_mozextension.js] +[browser_ext_windows_popup_title.js] diff --git a/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js new file mode 100644 index 0000000000..a43a49cc0a --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker.js @@ -0,0 +1,292 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals getBackgroundServiceWorkerRegistration, waitForServiceWorkerTerminated */ + +Services.scriptloader.loadSubScript( + new URL("head_serviceworker.js", gTestPath).href, + this +); + +add_task(assert_background_serviceworker_pref_enabled); + +add_task(async function test_serviceWorker_register_guarded_by_pref() { + // Test with backgroundServiceWorkeEnable set to true and the + // extensions.serviceWorkerRegist.allowed pref set to false. + // NOTE: the scenario with backgroundServiceWorkeEnable set to false + // is part of "browser_ext_background_serviceworker_pref_disabled.js". + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", false]], + }); + + let extensionData = { + files: { + "page.html": "<!DOCTYPE html><script src='page.js'></script>", + "page.js": async function() { + try { + await navigator.serviceWorker.register("sw.js"); + browser.test.fail( + `An extension page should not be able to register a serviceworker successfully` + ); + } catch (err) { + browser.test.assertEq( + String(err), + "SecurityError: The operation is insecure.", + "Got the expected error on registering a service worker from a script" + ); + } + browser.test.sendMessage("test-serviceWorker-register-disallowed"); + }, + "sw.js": "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Verify that an extension page can't register a moz-extension url + // as a service worker. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceWorker-register-disallowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); + + // Test again with the pref set to true. + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", true]], + }); + + extension = ExtensionTestUtils.loadExtension({ + files: { + ...extensionData.files, + "page.js": async function() { + try { + await navigator.serviceWorker.register("sw.js"); + } catch (err) { + browser.test.fail( + `Unexpected error on registering a service worker: ${err}` + ); + throw err; + } finally { + browser.test.sendMessage("test-serviceworker-register-allowed"); + } + }, + }, + }); + await extension.startup(); + + // Verify that an extension page can register a moz-extension url + // as a service worker if enabled by the related pref. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceworker-register-allowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_cache_api_allowed() { + // Verify that Cache API support for moz-extension url availability is also + // conditioned by the extensions.backgroundServiceWorker.enabled pref. + // NOTE: the scenario with backgroundServiceWorkeEnable set to false + // is part of "browser_ext_background_serviceworker_pref_disabled.js". + const extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + let cache = await window.caches.open("test-cache-api"); + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + + // Test that adding and requesting cached moz-extension urls + // works as well. + let url = browser.runtime.getURL("file.txt"); + await cache.add(url); + const content = await cache.match(url).then(res => res.text()); + browser.test.assertEq( + "file content", + content, + "Got the expected content from the cached moz-extension url" + ); + + // Test that deleting the cache storage works as expected. + browser.test.assertTrue( + await window.caches.delete("test-cache-api"), + "Cache deleted successfully" + ); + browser.test.assertTrue( + !(await window.caches.has("test-cache-api")), + "CacheStorage.has should resolve to false" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("test-cache-api-allowed"); + } + }, + files: { + "file.txt": "file content", + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-cache-api-allowed"); + await extension.unload(); +}); + +function createTestSWScript({ postMessageReply }) { + return ` + self.onmessage = msg => { + dump("Background ServiceWorker - onmessage handler\\n"); + msg.ports[0].postMessage("${postMessageReply}"); + dump("Background ServiceWorker - postMessage\\n"); + }; + dump("Background ServiceWorker - executed\\n"); + `; +} + +async function testServiceWorker({ extension, expectMessageReply }) { + // Verify that the WebExtensions framework has successfully registered the + // background service worker declared in the extension manifest. + const swRegInfo = getBackgroundServiceWorkerRegistration(extension); + + // Activate the background service worker by exchanging a message + // with it. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async browser => { + let msgFromV1 = await SpecialPowers.spawn( + browser, + [swRegInfo.scriptURL], + async url => { + const { active } = await content.navigator.serviceWorker.ready; + const { port1, port2 } = new content.MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = msg => resolve(msg.data); + active.postMessage("test", [port2]); + }); + } + ); + + Assert.deepEqual( + msgFromV1, + expectMessageReply, + "Got the expected reply from the extension service worker" + ); + } + ); +} + +function loadTestExtension({ version }) { + const postMessageReply = `reply:sw-v${version}`; + + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version, + background: { + service_worker: "sw.js", + }, + applications: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": createTestSWScript({ postMessageReply }), + }, + }); +} + +async function assertWorkerIsRunningInExtensionProcess(extension) { + // Activate the background service worker by exchanging a message + // with it. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async browser => { + const workerScriptURL = `moz-extension://${extension.uuid}/sw.js`; + const workerDebuggerURLs = await SpecialPowers.spawn( + browser, + [workerScriptURL], + async url => { + await content.navigator.serviceWorker.ready; + const wdm = Cc[ + "@mozilla.org/dom/workers/workerdebuggermanager;1" + ].getService(Ci.nsIWorkerDebuggerManager); + + return Array.from(wdm.getWorkerDebuggerEnumerator()) + .map(wd => { + return wd.url; + }) + .filter(swURL => swURL == url); + } + ); + + Assert.deepEqual( + workerDebuggerURLs, + [workerScriptURL], + "The worker should be running in the extension child process" + ); + } + ); +} + +add_task(async function test_background_serviceworker_with_no_ext_apis() { + const extensionV1 = loadTestExtension({ version: "1" }); + await extensionV1.startup(); + + const swRegInfo = getBackgroundServiceWorkerRegistration(extensionV1); + const { uuid } = extensionV1; + + await assertWorkerIsRunningInExtensionProcess(extensionV1); + await testServiceWorker({ + extension: extensionV1, + expectMessageReply: "reply:sw-v1", + }); + + // Load a new version of the same addon and verify that the + // expected worker script is being executed. + const extensionV2 = loadTestExtension({ version: "2" }); + await extensionV2.startup(); + is(extensionV2.uuid, uuid, "The extension uuid did not change as expected"); + + await testServiceWorker({ + extension: extensionV2, + expectMessageReply: "reply:sw-v2", + }); + + await Promise.all([ + extensionV2.unload(), + // test extension v1 wrapper has to be unloaded explicitly, otherwise + // will be detected as a failure by the test harness. + extensionV1.unload(), + ]); + await waitForServiceWorkerTerminated(swRegInfo); + await waitForServiceWorkerRegistrationsRemoved(extensionV2); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js new file mode 100644 index 0000000000..a2d9004801 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_background_serviceworker_pref_disabled.js @@ -0,0 +1,122 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function assert_background_serviceworker_pref_disabled() { + is( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + false, + "Expect extensions.backgroundServiceWorker.enabled to be false" + ); +}); + +add_task(async function test_background_serviceworker_disallowed() { + const id = "test-disallowed-worker@test"; + + const extensionData = { + manifest: { + background: { + service_worker: "sw.js", + }, + applicantions: { gecko: { id } }, + useAddonManager: "temporary", + }, + }; + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: /Reading manifest: Error processing background: background.service_worker is currently disabled/, + }, + ]); + }); + + const extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects( + extension.startup(), + /startup failed/, + "Startup failed with background.service_worker while disabled by pref" + ); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_serviceWorker_register_disallowed() { + // Verify that setting extensions.serviceWorkerRegist.allowed pref to false + // doesn't allow serviceWorker.register if backgroundServiceWorkeEnable is + // set to false + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.serviceWorkerRegister.allowed", true]], + }); + + let extensionData = { + files: { + "page.html": "<!DOCTYPE html><script src='page.js'></script>", + "page.js": async function() { + try { + await navigator.serviceWorker.register("sw.js"); + browser.test.fail( + `An extension page should not be able to register a serviceworker successfully` + ); + } catch (err) { + browser.test.assertEq( + String(err), + "SecurityError: The operation is insecure.", + "Got the expected error on registering a service worker from a script" + ); + } + browser.test.sendMessage("test-serviceWorker-register-disallowed"); + }, + "sw.js": "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Verify that an extension page can't register a moz-extension url + // as a service worker. + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: `moz-extension://${extension.uuid}/page.html`, + }, + async () => { + await extension.awaitMessage("test-serviceWorker-register-disallowed"); + } + ); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_cache_api_disallowed() { + // Verify that Cache API support for moz-extension url availability is also + // conditioned by the extensions.backgroundServiceWorker.enabled pref. + const extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + await window.caches.open("test-cache-api"); + browser.test.fail( + `An extension page should not be allowed to use the Cache API successfully` + ); + } catch (err) { + browser.test.assertEq( + String(err), + "SecurityError: The operation is insecure.", + "Got the expected error on registering a service worker from a script" + ); + } finally { + browser.test.sendMessage("test-cache-api-disallowed"); + } + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-cache-api-disallowed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js new file mode 100644 index 0000000000..f8672597cd --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_filters.js @@ -0,0 +1,138 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +async function testAppliedFilters(ext, expectedFilter, expectedFilterCount) { + let tempDir = FileUtils.getDir( + "TmpD", + [`testDownloadDir-${Math.random()}`], + true + ); + + let filterCount = 0; + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + MockFilePicker.displayDirectory = tempDir; + MockFilePicker.returnValue = MockFilePicker.returnCancel; + MockFilePicker.appendFiltersCallback = function(fp, val) { + const hexstr = "0x" + ("000" + val.toString(16)).substr(-3); + filterCount++; + if (filterCount < expectedFilterCount) { + is(val, expectedFilter, "Got expected filter: " + hexstr); + } else if (filterCount == expectedFilterCount) { + is(val, MockFilePicker.filterAll, "Got all files filter: " + hexstr); + } else { + is(val, null, "Got unexpected filter: " + hexstr); + } + }; + MockFilePicker.showCallback = function(fp) { + const filename = fp.defaultString; + info("MockFilePicker - save as: " + filename); + }; + + let manifest = { + description: ext, + permissions: ["downloads"], + }; + + const extension = ExtensionTestUtils.loadExtension({ + manifest: manifest, + + background: async function() { + let ext = chrome.runtime.getManifest().description; + await browser.test.assertRejects( + browser.downloads.download({ + url: "http://any-origin/any-path/any-resource", + filename: "any-file" + ext, + saveAs: true, + }), + "Download canceled by the user", + "expected request to be canceled" + ); + browser.test.sendMessage("canceled"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("canceled"); + await extension.unload(); + + is( + filterCount, + expectedFilterCount, + "Got correct number of filters: " + filterCount + ); + + MockFilePicker.cleanup(); + + tempDir.remove(true); +} + +// Missing extension +add_task(async function testDownload_missing_All() { + await testAppliedFilters("", null, 1); +}); + +// Unrecognized extension +add_task(async function testDownload_unrecognized_All() { + await testAppliedFilters(".xxx", null, 1); +}); + +// Recognized extensions +add_task(async function testDownload_html_HTML() { + await testAppliedFilters(".html", Ci.nsIFilePicker.filterHTML, 2); +}); + +add_task(async function testDownload_xhtml_HTML() { + await testAppliedFilters(".xhtml", Ci.nsIFilePicker.filterHTML, 2); +}); + +add_task(async function testDownload_txt_Text() { + await testAppliedFilters(".txt", Ci.nsIFilePicker.filterText, 2); +}); + +add_task(async function testDownload_text_Text() { + await testAppliedFilters(".text", Ci.nsIFilePicker.filterText, 2); +}); + +add_task(async function testDownload_jpe_Images() { + await testAppliedFilters(".jpe", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_tif_Images() { + await testAppliedFilters(".tif", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_webp_Images() { + await testAppliedFilters(".webp", Ci.nsIFilePicker.filterImages, 2); +}); + +add_task(async function testDownload_xml_XML() { + await testAppliedFilters(".xml", Ci.nsIFilePicker.filterXML, 2); +}); + +add_task(async function testDownload_aac_Audio() { + await testAppliedFilters(".aac", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_mp3_Audio() { + await testAppliedFilters(".mp3", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_wma_Audio() { + await testAppliedFilters(".wma", Ci.nsIFilePicker.filterAudio, 2); +}); + +add_task(async function testDownload_avi_Video() { + await testAppliedFilters(".avi", Ci.nsIFilePicker.filterVideo, 2); +}); + +add_task(async function testDownload_mp4_Video() { + await testAppliedFilters(".mp4", Ci.nsIFilePicker.filterVideo, 2); +}); + +add_task(async function testDownload_xvid_Video() { + await testAppliedFilters(".xvid", Ci.nsIFilePicker.filterVideo, 2); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js new file mode 100644 index 0000000000..b2931e0b6f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_downloads_referrer.js @@ -0,0 +1,82 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { BrowserTestUtils } = ChromeUtils.import( + "resource://testing-common/BrowserTestUtils.jsm" +); + +const URL_PATH = "browser/toolkit/components/extensions/test/browser/data"; +const TEST_URL = `http://example.com/${URL_PATH}/test_downloads_referrer.html`; +const DOWNLOAD_URL = `http://example.com/${URL_PATH}/test-download.txt`; + +async function triggerSaveAs({ selector }) { + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + let saveLinkCommand = window.document.getElementById("context-savelink"); + saveLinkCommand.doCommand(); +} + +add_task(function test_setup() { + const tempDir = Services.dirsvc.get("TmpD", Ci.nsIFile); + tempDir.append("test-download-dir"); + if (!tempDir.exists()) { + tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755); + } + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + registerCleanupFunction(function() { + MockFilePicker.cleanup(); + + if (tempDir.exists()) { + tempDir.remove(true); + } + }); + + MockFilePicker.displayDirectory = tempDir; + MockFilePicker.showCallback = function(fp) { + info("MockFilePicker: shown"); + const filename = fp.defaultString; + info("MockFilePicker: save as " + filename); + const destFile = tempDir.clone(); + destFile.append(filename); + MockFilePicker.setFiles([destFile]); + info("MockFilePicker: showCallback done"); + }; +}); + +add_task(async function test_download_item_referrer_info() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + async background() { + browser.downloads.onCreated.addListener(async downloadInfo => { + browser.test.sendMessage("download-on-created", downloadInfo); + }); + + // Call an API method implemented in the parent process to make sure + // registering the downloas.onCreated event listener has been completed. + await browser.runtime.getBrowserInfo(); + + browser.test.sendMessage("bg-page:ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-page:ready"); + + await BrowserTestUtils.withNewTab({ gBrowser, url: TEST_URL }, async () => { + await triggerSaveAs({ selector: "a.test-link" }); + const downloadInfo = await extension.awaitMessage("download-on-created"); + is(downloadInfo.url, DOWNLOAD_URL, "Got the expected download url"); + is(downloadInfo.referrer, TEST_URL, "Got the expected referrer"); + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_management_themes.js b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js new file mode 100644 index 0000000000..f74f418ace --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_management_themes.js @@ -0,0 +1,149 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PromiseTestUtils } = ChromeUtils.import( + "resource://testing-common/PromiseTestUtils.jsm" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); + +add_task(async function test_management_themes() { + const TEST_ID = "test_management_themes@tests.mozilla.com"; + + let theme = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Simple theme test", + version: "1.0", + description: "test theme", + theme: { + images: { + theme_frame: "image1.png", + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + useAddonManager: "temporary", + }); + + async function background(TEST_ID) { + browser.management.onInstalled.addListener(info => { + if (info.name == TEST_ID) { + return; + } + browser.test.log(`${info.name} was installed`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onInstalled", info.name); + }); + browser.management.onDisabled.addListener(info => { + browser.test.log(`${info.name} was disabled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onDisabled", info.name); + }); + browser.management.onEnabled.addListener(info => { + browser.test.log(`${info.name} was enabled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onEnabled", info.name); + }); + browser.management.onUninstalled.addListener(info => { + browser.test.log(`${info.name} was uninstalled`); + browser.test.assertEq(info.type, "theme", "addon is theme"); + browser.test.sendMessage("onUninstalled", info.name); + }); + + async function getAddon(type) { + let addons = await browser.management.getAll(); + let themes = addons.filter(addon => addon.type === "theme"); + // We get the 4 built-in themes plus the lwt and our addon. + browser.test.assertEq(5, themes.length, "got expected addons"); + // We should also get our test extension. + let testExtension = addons.find(addon => { + return addon.id === TEST_ID; + }); + browser.test.assertTrue( + !!testExtension, + `The extension with id ${TEST_ID} was returned by getAll.` + ); + let found; + for (let addon of themes) { + browser.test.assertEq(addon.type, "theme", "addon is theme"); + if (type == "theme" && addon.id.includes("temporary-addon")) { + found = addon; + } else if (type == "enabled" && addon.enabled) { + found = addon; + } + } + return found; + } + + browser.test.onMessage.addListener(async msg => { + let theme = await getAddon("theme"); + browser.test.assertEq( + theme.description, + "test theme", + "description is correct" + ); + browser.test.assertTrue(theme.enabled, "theme is enabled"); + await browser.management.setEnabled(theme.id, false); + + theme = await getAddon("theme"); + + browser.test.assertTrue(!theme.enabled, "theme is disabled"); + let addon = getAddon("enabled"); + browser.test.assertTrue(addon, "another theme was enabled"); + + await browser.management.setEnabled(theme.id, true); + theme = await getAddon("theme"); + addon = await getAddon("enabled"); + browser.test.assertEq(theme.id, addon.id, "theme is enabled"); + + browser.test.sendMessage("done"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: TEST_ID, + }, + }, + name: TEST_ID, + permissions: ["management"], + }, + background: `(${background})("${TEST_ID}")`, + useAddonManager: "temporary", + }); + await extension.startup(); + + await theme.startup(); + is( + await extension.awaitMessage("onInstalled"), + "Simple theme test", + "webextension theme installed" + ); + is(await extension.awaitMessage("onDisabled"), "Default", "default disabled"); + + extension.sendMessage("test"); + is(await extension.awaitMessage("onEnabled"), "Default", "default enabled"); + is( + await extension.awaitMessage("onDisabled"), + "Simple theme test", + "addon disabled" + ); + is( + await extension.awaitMessage("onEnabled"), + "Simple theme test", + "addon enabled" + ); + is(await extension.awaitMessage("onDisabled"), "Default", "default disabled"); + await extension.awaitMessage("done"); + + await Promise.all([theme.unload(), extension.awaitMessage("onUninstalled")]); + + is(await extension.awaitMessage("onEnabled"), "Default", "default enabled"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_test_mock.js b/toolkit/components/extensions/test/browser/browser_ext_test_mock.js new file mode 100644 index 0000000000..fc71cacc66 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_test_mock.js @@ -0,0 +1,45 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test verifies that the extension mocks behave consistently, regardless +// of test type (xpcshell vs browser test). +// See also toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js + +// Check the state of the extension object. This should be consistent between +// browser tests and xpcshell tests. +async function checkExtensionStartupAndUnload(ext) { + await ext.startup(); + Assert.ok(ext.id, "Extension ID should be available"); + Assert.ok(ext.uuid, "Extension UUID should be available"); + await ext.unload(); + // Once set nothing clears the UUID. + Assert.ok(ext.uuid, "Extension UUID exists after unload"); +} + +add_task(async function test_MockExtension() { + // When "useAddonManager" is set, a MockExtension is created in the main + // process, which does not necessarily behave identically to an Extension. + let ext = ExtensionTestUtils.loadExtension({ + // xpcshell/test_ext_test_mock.js tests "temporary", so here we use + // "permanent" to have even more test coverage. + useAddonManager: "permanent", + manifest: { applications: { gecko: { id: "@permanent-mock-extension" } } }, + }); + + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); + +add_task(async function test_generated_Extension() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: {}, + }); + + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js new file mode 100644 index 0000000000..f265a724e5 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_additional_backgrounds_alignment.js @@ -0,0 +1,102 @@ +"use strict"; + +// Case 1 - When there is a theme_frame image and additional_backgrounds_alignment is not specified. +// So background-position should default to "right top" +add_task(async function test_default_additional_backgrounds_alignment() { + const RIGHT_TOP = "100% 0%"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + additional_backgrounds: ["image1.png", "image1.png"], + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = document.documentElement; + let rootCS = window.getComputedStyle(docEl); + + Assert.equal( + rootCS.getPropertyValue("background-position"), + RIGHT_TOP, + "root only contains theme_frame alignment property" + ); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + + Assert.equal( + toolboxCS.getPropertyValue("background-position"), + RIGHT_TOP, + toolbox.id + + " only contains default additional backgrounds alignment property" + ); + + await extension.unload(); +}); + +// Case 2 - When there is a theme_frame image and additional_backgrounds_alignment is specified. +add_task(async function test_additional_backgrounds_alignment() { + const LEFT_BOTTOM = "0% 100%"; + const CENTER_CENTER = "50% 50%"; + const RIGHT_TOP = "100% 0%"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + additional_backgrounds: ["image1.png", "image1.png", "image1.png"], + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + properties: { + additional_backgrounds_alignment: [ + "left bottom", + "center center", + "right top", + ], + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = document.documentElement; + let rootCS = window.getComputedStyle(docEl); + + Assert.equal( + rootCS.getPropertyValue("background-position"), + RIGHT_TOP, + "root only contains theme_frame alignment property" + ); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolboxCS = window.getComputedStyle(toolbox); + + Assert.equal( + toolboxCS.getPropertyValue("background-position"), + LEFT_BOTTOM + ", " + CENTER_CENTER + ", " + RIGHT_TOP, + toolbox.id + " contains additional backgrounds alignment properties" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js new file mode 100644 index 0000000000..65e3a6c9bf --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_alpha_accentcolor.js @@ -0,0 +1,34 @@ +"use strict"; + +add_task(async function test_alpha_frame_color() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: "rgba(230, 128, 0, 0.1)", + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + // Add the event listener before loading the extension + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + Assert.equal( + style.backgroundColor, + "rgb(230, 128, 0)", + "Window background color should be opaque" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js new file mode 100644 index 0000000000..e7024b0479 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_arrowpanels.js @@ -0,0 +1,99 @@ +"use strict"; + +function openIdentityPopup() { + let promise = BrowserTestUtils.waitForEvent( + window, + "popupshown", + true, + event => event.target == gIdentityHandler._identityPopup + ); + gIdentityHandler._identityBox.click(); + return promise; +} + +function closeIdentityPopup() { + let promise = BrowserTestUtils.waitForEvent( + gIdentityHandler._identityPopup, + "popuphidden" + ); + gIdentityHandler._identityPopup.hidePopup(); + return promise; +} + +// This test checks applied WebExtension themes that attempt to change +// popup properties + +add_task(async function test_popup_styling(browser, accDoc) { + const POPUP_BACKGROUND_COLOR = "#FF0000"; + const POPUP_TEXT_COLOR = "#008000"; + const POPUP_BORDER_COLOR = "#0000FF"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + popup: POPUP_BACKGROUND_COLOR, + popup_text: POPUP_TEXT_COLOR, + popup_border: POPUP_BORDER_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "https://example.com" }, + async function(browser) { + await extension.startup(); + + // Open the information arrow panel + await openIdentityPopup(); + + let arrowContent = gIdentityHandler._identityPopup.shadowRoot.querySelector( + ".panel-arrowcontent" + ); + let arrowContentComputedStyle = window.getComputedStyle(arrowContent); + // Ensure popup background color was set properly + Assert.equal( + arrowContentComputedStyle.getPropertyValue("background-color"), + `rgb(${hexToRGB(POPUP_BACKGROUND_COLOR).join(", ")})`, + "Popup background color should have been themed" + ); + + // Ensure popup text color was set properly + Assert.equal( + arrowContentComputedStyle.getPropertyValue("color"), + `rgb(${hexToRGB(POPUP_TEXT_COLOR).join(", ")})`, + "Popup text color should have been themed" + ); + + Assert.equal( + arrowContentComputedStyle.getPropertyValue("--panel-description-color"), + `rgba(${hexToRGB(POPUP_TEXT_COLOR).join(", ")}, 0.65)`, + "Popup text description color should have been themed" + ); + + // Ensure popup border color was set properly + if (AppConstants.platform == "macosx") { + Assert.ok( + arrowContentComputedStyle + .getPropertyValue("box-shadow") + .includes(`rgb(${hexToRGB(POPUP_BORDER_COLOR).join(", ")})`), + "Popup border color should be set" + ); + } else { + testBorderColor(arrowContent, POPUP_BORDER_COLOR); + } + + await closeIdentityPopup(); + await extension.unload(); + } + ); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js new file mode 100644 index 0000000000..2c5a0123f4 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_autocomplete_popup.js @@ -0,0 +1,170 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// popup properties are applied correctly to the autocomplete bar. +const POPUP_COLOR = "#85A400"; +const POPUP_TEXT_COLOR_DARK = "#000000"; +const POPUP_TEXT_COLOR_BRIGHT = "#ffffff"; +const POPUP_SELECTED_COLOR = "#9400ff"; +const POPUP_SELECTED_TEXT_COLOR = "#09b9a6"; + +const POPUP_URL_COLOR_DARK = "#1c78d4"; +const POPUP_ACTION_COLOR_DARK = "#008f8a"; +const POPUP_URL_COLOR_BRIGHT = "#74c0ff"; +const POPUP_ACTION_COLOR_BRIGHT = "#30e60b"; + +const SEARCH_TERM = "urlbar-reflows-" + Date.now(); + +XPCOMUtils.defineLazyModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.jsm", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.jsm", +}); + +add_task(async function setup() { + await PlacesUtils.history.clear(); + const NUM_VISITS = 10; + let visits = []; + + for (let i = 0; i < NUM_VISITS; ++i) { + visits.push({ + uri: `http://example.com/urlbar-reflows-${i}`, + title: `Reflow test for URL bar entry #${i} - ${SEARCH_TERM}`, + }); + } + + await PlacesTestUtils.addVisits(visits); + + registerCleanupFunction(async function() { + await PlacesUtils.history.clear(); + }); +}); + +add_task(async function test_popup_url() { + // Load extension with brighttext not set + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field_focus: POPUP_COLOR, + toolbar_field_text_focus: POPUP_TEXT_COLOR_DARK, + popup_highlight: POPUP_SELECTED_COLOR, + popup_highlight_text: POPUP_SELECTED_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let maxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults"); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:mozilla" + ); + registerCleanupFunction(async function() { + await PlacesUtils.history.clear(); + await BrowserTestUtils.removeTab(tab); + }); + + let visits = []; + + for (let i = 0; i < maxResults; i++) { + visits.push({ uri: makeURI("http://example.com/autocomplete/?" + i) }); + } + + await PlacesTestUtils.addVisits(visits); + await UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value: "example.com/autocomplete", + }); + await UrlbarTestUtils.waitForAutocompleteResultAt(window, maxResults - 1); + + Assert.equal( + UrlbarTestUtils.getResultCount(window), + maxResults, + "Should get maxResults=" + maxResults + " results" + ); + + // Set the selected attribute to true to test the highlight popup properties + UrlbarTestUtils.setSelectedRowIndex(window, 1); + let actionResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + let urlResult = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + let resultCS = window.getComputedStyle(urlResult.element.row._content); + + Assert.equal( + resultCS.backgroundColor, + `rgb(${hexToRGB(POPUP_SELECTED_COLOR).join(", ")})`, + `Popup highlight background color should be set to ${POPUP_SELECTED_COLOR}` + ); + + Assert.equal( + resultCS.color, + `rgb(${hexToRGB(POPUP_SELECTED_TEXT_COLOR).join(", ")})`, + `Popup highlight color should be set to ${POPUP_SELECTED_TEXT_COLOR}` + ); + + // Now set the index to somewhere not on the first two, so that we can test both + // url and action text colors. + UrlbarTestUtils.setSelectedRowIndex(window, 2); + + Assert.equal( + window.getComputedStyle(urlResult.element.url).color, + `rgb(${hexToRGB(POPUP_URL_COLOR_DARK).join(", ")})`, + `Urlbar popup url color should be set to ${POPUP_URL_COLOR_DARK}` + ); + + Assert.equal( + window.getComputedStyle(actionResult.element.action).color, + `rgb(${hexToRGB(POPUP_ACTION_COLOR_DARK).join(", ")})`, + `Urlbar popup action color should be set to ${POPUP_ACTION_COLOR_DARK}` + ); + + await extension.unload(); + + // Load a manifest with popup_text being bright. Test for bright text properties. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field_focus: POPUP_COLOR, + toolbar_field_text_focus: POPUP_TEXT_COLOR_BRIGHT, + popup_highlight: POPUP_SELECTED_COLOR, + popup_highlight_text: POPUP_SELECTED_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlResult.element.url).color, + `rgb(${hexToRGB(POPUP_URL_COLOR_BRIGHT).join(", ")})`, + `Urlbar popup url color should be set to ${POPUP_URL_COLOR_BRIGHT}` + ); + + Assert.equal( + window.getComputedStyle(actionResult.element.action).color, + `rgb(${hexToRGB(POPUP_ACTION_COLOR_BRIGHT).join(", ")})`, + `Urlbar popup action color should be set to ${POPUP_ACTION_COLOR_BRIGHT}` + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js new file mode 100644 index 0000000000..4366764a20 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_chromeparity.js @@ -0,0 +1,161 @@ +"use strict"; + +add_task(async function test_support_theme_frame() { + const FRAME_COLOR = [71, 105, 91]; + const TAB_TEXT_COLOR = [0, 0, 0]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + + Assert.ok( + docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should be set" + ); + + Assert.equal( + docEl.getAttribute("lwthemetextcolor"), + "dark", + "LWT text color attribute should be set" + ); + + let style = window.getComputedStyle(docEl); + Assert.ok( + style.backgroundImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${style.backgroundImage}` + ); + Assert.equal( + style.backgroundColor, + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Expected correct background color" + ); + Assert.equal( + style.color, + "rgb(" + TAB_TEXT_COLOR.join(", ") + ")", + "Expected correct text color" + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + + Assert.ok( + !docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should not be set" + ); + + Assert.ok( + !docEl.hasAttribute("lwthemetextcolor"), + "LWT text color attribute should not be set" + ); +}); + +add_task(async function test_support_theme_frame_inactive() { + const FRAME_COLOR = [71, 105, 91]; + const FRAME_COLOR_INACTIVE = [255, 0, 0]; + const TAB_TEXT_COLOR = [207, 221, 192]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + frame_inactive: FRAME_COLOR_INACTIVE, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + Assert.equal( + style.backgroundColor, + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Window background is set to the colors.frame property" + ); + + // Now we'll open a new window to see if the inactive browser accent color changed + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal( + style.backgroundColor, + "rgb(" + FRAME_COLOR_INACTIVE.join(", ") + ")", + `Inactive window background color should be ${FRAME_COLOR_INACTIVE}` + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_lack_of_theme_frame_inactive() { + const FRAME_COLOR = [71, 105, 91]; + const TAB_TEXT_COLOR = [207, 221, 192]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + Assert.equal( + style.backgroundColor, + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Window background is set to the colors.frame property" + ); + + // Now we'll open a new window to make sure the inactive browser accent color stayed the same + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + Assert.equal( + style.backgroundColor, + "rgb(" + FRAME_COLOR.join(", ") + ")", + "Inactive window background should not change if colors.frame_inactive isn't set" + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js new file mode 100644 index 0000000000..4a379edfbf --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_getCurrent.js @@ -0,0 +1,203 @@ +"use strict"; + +// This test checks whether browser.theme.getCurrent() works correctly in different +// configurations and with different parameter. + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; + +add_task(async function test_get_current() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + const ACCENT_COLOR_1 = "#a14040"; + const TEXT_COLOR_1 = "#fac96e"; + + const ACCENT_COLOR_2 = "#03fe03"; + const TEXT_COLOR_2 = "#0ef325"; + + const theme1 = { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }; + + const theme2 = { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }; + + function ensureWindowFocused(winId) { + browser.test.log("Waiting for focused window to be " + winId); + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { + let listener = windowId => { + if (windowId === winId) { + browser.windows.onFocusChanged.removeListener(listener); + resolve(); + } + }; + // We first add a listener and then check whether the window is + // focused using .get(), because the .get() Promise resolving + // could race with the listener running, in which case we'd + // never be notified. + browser.windows.onFocusChanged.addListener(listener); + let { focused } = await browser.windows.get(winId); + if (focused) { + browser.windows.onFocusChanged.removeListener(listener); + resolve(); + } + }); + } + + function testTheme1(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image1.png"), + "Theme 1 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_1, + returnedTheme.colors.frame, + "Theme 1 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_1, + returnedTheme.colors.tab_background_text, + "Theme 1 tab_background_text color should be applied" + ); + } + + function testTheme2(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image2.png"), + "Theme 2 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_2, + returnedTheme.colors.frame, + "Theme 2 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_2, + returnedTheme.colors.tab_background_text, + "Theme 2 tab_background_text color should be applied" + ); + } + + function testEmptyTheme(returnedTheme) { + browser.test.assertEq( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(returnedTheme), + JSON.stringify(returnedTheme, null, 2) + ); + } + + browser.test.log("Testing getCurrent() with initial unthemed window"); + const firstWin = await browser.windows.getCurrent(); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log("Testing getCurrent() with after theme.update()"); + await browser.theme.update(theme1); + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log( + "Testing getCurrent() with after theme.update(windowId)" + ); + const secondWin = await browser.windows.create(); + await ensureWindowFocused(secondWin.id); + await browser.theme.update(secondWin.id, theme2); + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after window focus change"); + let focusChanged = ensureWindowFocused(firstWin.id); + await browser.windows.update(firstWin.id, { focused: true }); + await focusChanged; + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log( + "Testing getCurrent() after another window focus change" + ); + focusChanged = ensureWindowFocused(secondWin.id); + await browser.windows.update(secondWin.id, { focused: true }); + await focusChanged; + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.reset(windowId)"); + await browser.theme.reset(firstWin.id); + testTheme2(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log( + "Testing getCurrent() after reset and window focus change" + ); + focusChanged = ensureWindowFocused(firstWin.id); + await browser.windows.update(firstWin.id, { focused: true }); + await focusChanged; + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.update(windowId)"); + await browser.theme.update(firstWin.id, theme1); + testTheme1(await browser.theme.getCurrent()); + testTheme1(await browser.theme.getCurrent(firstWin.id)); + testTheme2(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after theme.reset()"); + await browser.theme.reset(); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + testEmptyTheme(await browser.theme.getCurrent(secondWin.id)); + + browser.test.log("Testing getCurrent() after closing a window"); + await browser.windows.remove(secondWin.id); + testEmptyTheme(await browser.theme.getCurrent()); + testEmptyTheme(await browser.theme.getCurrent(firstWin.id)); + + browser.test.log("Testing update calls with invalid window ID"); + await browser.test.assertRejects( + browser.theme.reset(secondWin.id), + /Invalid window/, + "Invalid window should throw" + ); + await browser.test.assertRejects( + browser.theme.update(secondWin.id, theme2), + /Invalid window/, + "Invalid window should throw" + ); + browser.test.notifyPass("get_current"); + }, + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + }); + + await extension.startup(); + await extension.awaitFinish("get_current"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js new file mode 100644 index 0000000000..34c7162810 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_onUpdated.js @@ -0,0 +1,154 @@ +"use strict"; + +// This test checks whether browser.theme.onUpdated works correctly with different +// types of dynamic theme updates. + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; + +add_task(async function test_on_updated() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + const ACCENT_COLOR_1 = "#a14040"; + const TEXT_COLOR_1 = "#fac96e"; + + const ACCENT_COLOR_2 = "#03fe03"; + const TEXT_COLOR_2 = "#0ef325"; + + const theme1 = { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }; + + const theme2 = { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }; + + function testTheme1(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image1.png"), + "Theme 1 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_1, + returnedTheme.colors.frame, + "Theme 1 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_1, + returnedTheme.colors.tab_background_text, + "Theme 1 tab_background_text color should be applied" + ); + } + + function testTheme2(returnedTheme) { + browser.test.assertTrue( + returnedTheme.images.theme_frame.includes("image2.png"), + "Theme 2 theme_frame image should be applied" + ); + browser.test.assertEq( + ACCENT_COLOR_2, + returnedTheme.colors.frame, + "Theme 2 frame color should be applied" + ); + browser.test.assertEq( + TEXT_COLOR_2, + returnedTheme.colors.tab_background_text, + "Theme 2 tab_background_text color should be applied" + ); + } + + const firstWin = await browser.windows.getCurrent(); + const secondWin = await browser.windows.create(); + + const onceThemeUpdated = () => + new Promise(resolve => { + const listener = updateInfo => { + browser.theme.onUpdated.removeListener(listener); + resolve(updateInfo); + }; + browser.theme.onUpdated.addListener(listener); + }); + + browser.test.log("Testing update with no windowId parameter"); + let updateInfo1 = onceThemeUpdated(); + await browser.theme.update(theme1); + updateInfo1 = await updateInfo1; + testTheme1(updateInfo1.theme); + browser.test.assertTrue( + !updateInfo1.windowId, + "No window id on first update" + ); + + browser.test.log("Testing update with windowId parameter"); + let updateInfo2 = onceThemeUpdated(); + await browser.theme.update(secondWin.id, theme2); + updateInfo2 = await updateInfo2; + testTheme2(updateInfo2.theme); + browser.test.assertEq( + secondWin.id, + updateInfo2.windowId, + "window id on second update" + ); + + browser.test.log("Testing reset with windowId parameter"); + let updateInfo3 = onceThemeUpdated(); + await browser.theme.reset(firstWin.id); + updateInfo3 = await updateInfo3; + browser.test.assertEq( + 0, + Object.keys(updateInfo3.theme).length, + "Empty theme given on reset" + ); + browser.test.assertEq( + firstWin.id, + updateInfo3.windowId, + "window id on third update" + ); + + browser.test.log("Testing reset with no windowId parameter"); + let updateInfo4 = onceThemeUpdated(); + await browser.theme.reset(); + updateInfo4 = await updateInfo4; + browser.test.assertEq( + 0, + Object.keys(updateInfo4.theme).length, + "Empty theme given on reset" + ); + browser.test.assertTrue( + !updateInfo4.windowId, + "no window id on fourth update" + ); + + browser.test.log("Cleaning up test"); + await browser.windows.remove(secondWin.id); + browser.test.notifyPass("onUpdated"); + }, + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + }); + + await extension.startup(); + await extension.awaitFinish("onUpdated"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js new file mode 100644 index 0000000000..34e719262d --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_dynamic_updates.js @@ -0,0 +1,185 @@ +"use strict"; + +// PNG image data for a simple red dot. +const BACKGROUND_1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +const ACCENT_COLOR_1 = "#a14040"; +const TEXT_COLOR_1 = "#fac96e"; + +// PNG image data for the Mozilla dino head. +const BACKGROUND_2 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABGdBTUEAAK/INwWK6QAAABl0RVh0U29mdHdhcmUAQWRvYmUgSW1hZ2VSZWFkeXHJZTwAAAHWSURBVHjaYvz//z8DJQAggJiQOe/fv2fv7Oz8rays/N+VkfG/iYnJfyD/1+rVq7ffu3dPFpsBAAHEAHIBCJ85c8bN2Nj4vwsDw/8zQLwKiO8CcRoQu0DxqlWrdsHUwzBAAIGJmTNnPgYa9j8UqhFElwPxf2MIDeIrKSn9FwSJoRkAEEAM0DD4DzMAyPi/G+QKY4hh5WAXGf8PDQ0FGwJ22d27CjADAAIIrLmjo+MXA9R2kAHvGBA2wwx6B8W7od6CeQcggKCmCEL8bgwxYCbUIGTDVkHDBia+CuotgACCueD3TDQN75D4xmAvCoK9ARMHBzAw0AECiBHkAlC0Mdy7x9ABNA3obAZXIAa6iKEcGlMVQHwWyjYuL2d4v2cPg8vZswx7gHyAAAK7AOif7SAbOqCmn4Ha3AHFsIDtgPq/vLz8P4MSkJ2W9h8ggBjevXvHDo4FQUQg/kdypqCg4H8lUIACnQ/SOBMYI8bAsAJFPcj1AAEEjwVQqLpAbXmH5BJjqI0gi9DTAAgDBBCcAVLkgmQ7yKCZxpCQxqUZhAECCJ4XgMl493ug21ZD+aDAXH0WLM4A9MZPXJkJIIAwTAR5pQMalaCABQUULttBGCCAGCnNzgABBgAMJ5THwGvJLAAAAABJRU5ErkJggg=="; +const ACCENT_COLOR_2 = "#03fe03"; +const TEXT_COLOR_2 = "#0ef325"; + +function hexToRGB(hex) { + hex = parseInt(hex.indexOf("#") > -1 ? hex.substring(1) : hex, 16); + return ( + "rgb(" + [hex >> 16, (hex & 0x00ff00) >> 8, hex & 0x0000ff].join(", ") + ")" + ); +} + +function validateTheme(backgroundImage, accentColor, textColor, isLWT) { + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + if (isLWT) { + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwthemetextcolor"), + "bright", + "LWT text color attribute should be set" + ); + } + + Assert.ok( + style.backgroundImage.includes(backgroundImage), + "Expected correct background image" + ); + if (accentColor.startsWith("#")) { + accentColor = hexToRGB(accentColor); + } + if (textColor.startsWith("#")) { + textColor = hexToRGB(textColor); + } + Assert.equal( + style.backgroundColor, + accentColor, + "Expected correct accent color" + ); + Assert.equal(style.color, textColor, "Expected correct text color"); +} + +add_task(async function test_dynamic_theme_updates() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + files: { + "image1.png": BACKGROUND_1, + "image2.png": BACKGROUND_2, + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + let defaultStyle = window.getComputedStyle(window.document.documentElement); + await extension.startup(); + + extension.sendMessage("update-theme", { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme("image1.png", ACCENT_COLOR_1, TEXT_COLOR_1, true); + + // Check with the LWT aliases (to update on Firefox 69, because the + // LWT aliases are going to be removed). + extension.sendMessage("update-theme", { + images: { + theme_frame: "image2.png", + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme("image2.png", ACCENT_COLOR_2, TEXT_COLOR_2, true); + + extension.sendMessage("reset-theme"); + + await extension.awaitMessage("theme-reset"); + + let { backgroundImage, backgroundColor, color } = defaultStyle; + validateTheme(backgroundImage, backgroundColor, color, false); + + await extension.unload(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_dynamic_theme_updates_with_data_url() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + let defaultStyle = window.getComputedStyle(window.document.documentElement); + await extension.startup(); + + extension.sendMessage("update-theme", { + images: { + theme_frame: BACKGROUND_1, + }, + colors: { + frame: ACCENT_COLOR_1, + tab_background_text: TEXT_COLOR_1, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme(BACKGROUND_1, ACCENT_COLOR_1, TEXT_COLOR_1, true); + + extension.sendMessage("update-theme", { + images: { + theme_frame: BACKGROUND_2, + }, + colors: { + frame: ACCENT_COLOR_2, + tab_background_text: TEXT_COLOR_2, + }, + }); + + await extension.awaitMessage("theme-updated"); + + validateTheme(BACKGROUND_2, ACCENT_COLOR_2, TEXT_COLOR_2, true); + + extension.sendMessage("reset-theme"); + + await extension.awaitMessage("theme-reset"); + + let { backgroundImage, backgroundColor, color } = defaultStyle; + validateTheme(backgroundImage, backgroundColor, color, false); + + await extension.unload(); + + let docEl = window.document.documentElement; + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js new file mode 100644 index 0000000000..7b362498e7 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_experiment.js @@ -0,0 +1,401 @@ +"use strict"; + +const { AddonSettings } = ChromeUtils.import( + "resource://gre/modules/addons/AddonSettings.jsm" +); + +// This test checks whether the theme experiments work +add_task(async function test_experiment_static_theme() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + some_color_property: "#ff00ff", + }, + images: { + some_image_property: "background.jpg", + }, + properties: { + some_random_property: "no-repeat", + }, + }, + theme_experiment: { + colors: { + some_color_property: "--some-color-property", + }, + images: { + some_image_property: "--some-image-property", + }, + properties: { + some_random_property: "--some-random-property", + }, + }, + }, + }); + + const root = window.document.documentElement; + + is( + root.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + root.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + root.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + + await extension.startup(); + + const testExperimentApplied = rootEl => { + if (AddonSettings.EXPERIMENTS_ENABLED) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + hexToCSS("#ff00ff"), + "Color property should be parsed and set." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .startsWith("url("), + "Image property should be parsed." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .endsWith("background.jpg)"), + "Image property should be set." + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "no-repeat", + "Generic Property should be set." + ); + } else { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + }; + + info("Testing that current window updated with the experiment applied"); + testExperimentApplied(root); + + info("Testing that new window initialized with the experiment applied"); + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const newWindowRoot = newWindow.document.documentElement; + testExperimentApplied(newWindowRoot); + + await extension.unload(); + + info("Testing that both windows unapplied the experiment"); + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + await BrowserTestUtils.closeWindow(newWindow); +}); + +add_task(async function test_experiment_dynamic_theme() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + theme_experiment: { + colors: { + some_color_property: "--some-color-property", + }, + images: { + some_image_property: "--some-image-property", + }, + properties: { + some_random_property: "--some-random-property", + }, + }, + }, + background() { + const theme = { + colors: { + some_color_property: "#ff00ff", + }, + images: { + some_image_property: "background.jpg", + }, + properties: { + some_random_property: "no-repeat", + }, + }; + browser.test.onMessage.addListener(msg => { + if (msg === "update-theme") { + browser.theme.update(theme).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + await extension.startup(); + + const root = window.document.documentElement; + + is( + root.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + root.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + root.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + + extension.sendMessage("update-theme"); + await extension.awaitMessage("theme-updated"); + + const testExperimentApplied = rootEl => { + if (AddonSettings.EXPERIMENTS_ENABLED) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + hexToCSS("#ff00ff"), + "Color property should be parsed and set." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .startsWith("url("), + "Image property should be parsed." + ); + ok( + rootEl.style + .getPropertyValue("--some-image-property") + .endsWith("background.jpg)"), + "Image property should be set." + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "no-repeat", + "Generic Property should be set." + ); + } else { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + }; + testExperimentApplied(root); + + const newWindow = await BrowserTestUtils.openNewBrowserWindow(); + const newWindowRoot = newWindow.document.documentElement; + + testExperimentApplied(newWindowRoot); + + extension.sendMessage("reset-theme"); + await extension.awaitMessage("theme-reset"); + + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + + extension.sendMessage("update-theme"); + await extension.awaitMessage("theme-updated"); + + testExperimentApplied(root); + testExperimentApplied(newWindowRoot); + + await extension.unload(); + + for (const rootEl of [root, newWindowRoot]) { + is( + rootEl.style.getPropertyValue("--some-color-property"), + "", + "Color property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-image-property"), + "", + "Image property should be unset" + ); + is( + rootEl.style.getPropertyValue("--some-random-property"), + "", + "Generic Property should be unset." + ); + } + + await BrowserTestUtils.closeWindow(newWindow); +}); + +add_task(async function test_experiment_stylesheet() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + menu_button_background: "#ff00ff", + }, + }, + theme_experiment: { + stylesheet: "experiment.css", + colors: { + menu_button_background: "--menu-button-background", + }, + }, + }, + files: { + "experiment.css": `#PanelUI-menu-button { + background-color: var(--menu-button-background); + fill: white; + }`, + }, + }); + + const root = window.document.documentElement; + const menuButton = document.getElementById("PanelUI-menu-button"); + const computedStyle = window.getComputedStyle(menuButton); + const expectedColor = hexToCSS("#ff00ff"); + const expectedFill = hexToCSS("#ffffff"); + + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); + + await extension.startup(); + + if (AddonSettings.EXPERIMENTS_ENABLED) { + // Wait for stylesheet load. + await BrowserTestUtils.waitForCondition( + () => computedStyle.fill === expectedFill + ); + + is( + root.style.getPropertyValue("--menu-button-background"), + expectedColor, + "Variable should be parsed and set." + ); + is( + computedStyle.backgroundColor, + expectedColor, + "Menu button should be have correct background" + ); + is( + computedStyle.fill, + expectedFill, + "Menu button should be have correct fill" + ); + } else { + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); + } + + await extension.unload(); + + is( + root.style.getPropertyValue("--menu-button-background"), + "", + "Variable should be unset" + ); + isnot( + computedStyle.backgroundColor, + expectedColor, + "Menu button should not have custom background" + ); + isnot( + computedStyle.fill, + expectedFill, + "Menu button should not have stylesheet fill" + ); +}); + +add_task(async function cleanup() { + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js new file mode 100644 index 0000000000..fe9689bc74 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_findbar.js @@ -0,0 +1,217 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the toolbar and toolbar_field properties also theme the findbar. + +add_task(async function test_support_toolbar_properties_on_findbar() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + const ACCENT_COLOR_INACTIVE = "#ffff00"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + frame_inactive: ACCENT_COLOR_INACTIVE, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_button = gFindBar.getElement("highlight"); + + info("Checking findbar background is set as toolbar color"); + Assert.equal( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR), + "Findbar background color should be the same as toolbar background color." + ); + + info("Checking findbar and button text color is set as toolbar text color"); + Assert.equal( + window.getComputedStyle(gFindBar).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Findbar text color should be the same as toolbar text color." + ); + Assert.equal( + window.getComputedStyle(findbar_button).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Findbar button text color should be the same as toolbar text color." + ); + + // Open a new window to check frame_inactive + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + Assert.equal( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR_INACTIVE), + "Findbar background changed in inactive window." + ); + await BrowserTestUtils.closeWindow(window2); + + await extension.unload(); +}); + +add_task(async function test_support_toolbar_field_properties_on_findbar() { + const TOOLBAR_FIELD_COLOR = "#ff00ff"; + const TOOLBAR_FIELD_TEXT_COLOR = "#9400ff"; + const TOOLBAR_FIELD_BORDER_COLOR = "#ffffff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_COLOR, + toolbar_field_text: TOOLBAR_FIELD_TEXT_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER_COLOR, + }, + }, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_textbox = gFindBar.getElement("findbar-textbox"); + + let findbar_prev_button = gFindBar.getElement("find-previous"); + + let findbar_next_button = gFindBar.getElement("find-next"); + + info( + "Checking findbar textbox background is set as toolbar field background color" + ); + Assert.equal( + window.getComputedStyle(findbar_textbox).backgroundColor, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Findbar textbox background color should be the same as toolbar field color." + ); + + info("Checking findbar textbox color is set as toolbar field text color"); + Assert.equal( + window.getComputedStyle(findbar_textbox).color, + hexToCSS(TOOLBAR_FIELD_TEXT_COLOR), + "Findbar textbox text color should be the same as toolbar field text color." + ); + testBorderColor(findbar_textbox, TOOLBAR_FIELD_BORDER_COLOR); + testBorderColor(findbar_prev_button, TOOLBAR_FIELD_BORDER_COLOR); + testBorderColor(findbar_next_button, TOOLBAR_FIELD_BORDER_COLOR); + + await extension.unload(); +}); + +// Test that theme properties are *not* applied with a theme_frame (see bug 1506913) +add_task(async function test_toolbar_properties_on_findbar_with_theme_frame() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_button = gFindBar.getElement("highlight"); + + info("Checking findbar background is *not* set as toolbar color"); + Assert.notEqual( + window.getComputedStyle(gFindBar).backgroundColor, + hexToCSS(ACCENT_COLOR), + "Findbar background color should not be set by theme." + ); + + info( + "Checking findbar and button text color is *not* set as toolbar text color" + ); + Assert.notEqual( + window.getComputedStyle(gFindBar).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Findbar text color should not be set by theme." + ); + Assert.notEqual( + window.getComputedStyle(findbar_button).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Findbar button text color should not be set by theme." + ); + + await extension.unload(); +}); + +add_task( + async function test_toolbar_field_properties_on_findbar_with_theme_frame() { + const TOOLBAR_FIELD_COLOR = "#ff00ff"; + const TOOLBAR_FIELD_TEXT_COLOR = "#9400ff"; + const TOOLBAR_FIELD_BORDER_COLOR = "#ffffff"; + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu early in the test. + gBrowser.selectedTab.focus(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_COLOR, + toolbar_field_text: TOOLBAR_FIELD_TEXT_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + await gBrowser.getFindBar(); + + let findbar_textbox = gFindBar.getElement("findbar-textbox"); + + Assert.notEqual( + window.getComputedStyle(findbar_textbox).backgroundColor, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Findbar textbox background color should not be set by theme." + ); + + Assert.notEqual( + window.getComputedStyle(findbar_textbox).color, + hexToCSS(TOOLBAR_FIELD_TEXT_COLOR), + "Findbar textbox text color should not be set by theme." + ); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js new file mode 100644 index 0000000000..981f32d7fb --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_getCurrent_differentExt.js @@ -0,0 +1,66 @@ +"use strict"; + +// This test checks whether browser.theme.getCurrent() works correctly when theme +// does not originate from extension querying the theme. + +add_task(async function test_getcurrent() { + const theme = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.theme.onUpdated.addListener(() => { + browser.theme.getCurrent().then(theme => { + browser.test.sendMessage("theme-updated", theme); + }); + }); + }, + }); + + await extension.startup(); + + info("Testing getCurrent after static theme startup"); + let updatedPromise = extension.awaitMessage("theme-updated"); + await theme.startup(); + let receivedTheme = await updatedPromise; + Assert.ok( + receivedTheme.images.theme_frame.includes("image1.png"), + "getCurrent returns correct theme_frame image" + ); + Assert.equal( + receivedTheme.colors.frame, + ACCENT_COLOR, + "getCurrent returns correct frame color" + ); + Assert.equal( + receivedTheme.colors.tab_background_text, + TEXT_COLOR, + "getCurrent returns correct tab_background_text color" + ); + + info("Testing getCurrent after static theme unload"); + updatedPromise = extension.awaitMessage("theme-updated"); + await theme.unload(); + receivedTheme = await updatedPromise; + Assert.equal( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(receivedTheme), + "getCurrent returns empty theme" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js new file mode 100644 index 0000000000..083eb85486 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_highlight.js @@ -0,0 +1,61 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the color of the font and background in a selection are applied properly. +ChromeUtils.import( + "resource://testing-common/CustomizableUITestUtils.jsm", + this +); +let gCUITestUtils = new CustomizableUITestUtils(window); +add_task(async function setup() { + await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_support_selection() { + const HIGHLIGHT_TEXT_COLOR = "#9400FF"; + const HIGHLIGHT_COLOR = "#F89919"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + toolbar_field_highlight: HIGHLIGHT_COLOR, + toolbar_field_highlight_text: HIGHLIGHT_TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + let fields = [ + gURLBar.inputField, + document.querySelector("#searchbar .searchbar-textbox"), + ].filter(field => { + let bounds = field.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + Assert.equal(fields.length, 2, "Should be testing two elements"); + + info( + `Checking background colors and colors for ${fields.length} toolbar input fields.` + ); + for (let field of fields) { + info(`Testing ${field.id || field.className}`); + Assert.equal( + window.getComputedStyle(field, "::selection").backgroundColor, + hexToCSS(HIGHLIGHT_COLOR), + "Input selection background should be set." + ); + Assert.equal( + window.getComputedStyle(field, "::selection").color, + hexToCSS(HIGHLIGHT_TEXT_COLOR), + "Input selection color should be set." + ); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js new file mode 100644 index 0000000000..4917f6f830 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_incognito.js @@ -0,0 +1,81 @@ +"use strict"; + +add_task(async function test_theme_incognito_not_allowed() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.allowPrivateBrowsingByDefault", false]], + }); + + let windowExtension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + const theme = { + colors: { + frame: "black", + tab_background_text: "black", + }, + }; + let window = await browser.windows.create({ incognito: true }); + browser.test.onMessage.addListener(async message => { + if (message == "update") { + browser.theme.update(window.id, theme); + return; + } + await browser.windows.remove(window.id); + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready", window.id); + }, + manifest: { + permissions: ["theme"], + }, + }); + await windowExtension.startup(); + let wId = await windowExtension.awaitMessage("ready"); + + async function background(windowId) { + const theme = { + colors: { + frame: "black", + tab_background_text: "black", + }, + }; + + browser.theme.onUpdated.addListener(info => { + browser.test.log("got theme onChanged"); + browser.test.fail("theme"); + }); + await browser.test.assertRejects( + browser.theme.getCurrent(windowId), + /Invalid window ID/, + "API should reject getting window theme" + ); + await browser.test.assertRejects( + browser.theme.update(windowId, theme), + /Invalid window ID/, + "API should reject updating theme" + ); + await browser.test.assertRejects( + browser.theme.reset(windowId), + /Invalid window ID/, + "API should reject reseting theme on window" + ); + + browser.test.sendMessage("start"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${wId})`, + manifest: { + permissions: ["theme"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("start"); + windowExtension.sendMessage("update"); + + windowExtension.sendMessage("close"); + await windowExtension.awaitMessage("done"); + await windowExtension.unload(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js new file mode 100644 index 0000000000..af2eef6ffb --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_lwtsupport.js @@ -0,0 +1,57 @@ +"use strict"; + +const DEFAULT_THEME_BG_COLOR = "rgb(255, 255, 255)"; +const DEFAULT_THEME_TEXT_COLOR = "rgb(0, 0, 0)"; + +add_task(async function test_deprecated_LWT_properties_ignored() { + // This test uses deprecated theme properties, so warnings are expected. + ExtensionTestUtils.failOnSchemaWarnings(false); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + headerURL: "image1.png", + }, + colors: { + accentcolor: ACCENT_COLOR, + textcolor: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.ok( + !docEl.hasAttribute("lwtheme-image"), + "LWT image attribute should not be set on deprecated headerURL alias" + ); + Assert.equal( + docEl.getAttribute("lwthemetextcolor"), + "dark", + "LWT text color attribute should not be set on deprecated textcolor alias" + ); + + Assert.equal( + style.backgroundColor, + DEFAULT_THEME_BG_COLOR, + "Expected default theme background color" + ); + Assert.equal( + style.color, + DEFAULT_THEME_TEXT_COLOR, + "Expected default theme text color" + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js new file mode 100644 index 0000000000..1395647683 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_multiple_backgrounds.js @@ -0,0 +1,216 @@ +"use strict"; + +add_task(async function test_support_backgrounds_position() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face1.png", + additional_backgrounds: ["face2.png", "face2.png", "face2.png"], + }, + colors: { + frame: `rgb(${FRAME_COLOR.join(",")})`, + tab_background_text: `rgb(${TAB_BACKGROUND_TEXT_COLOR.join(",")})`, + }, + properties: { + additional_backgrounds_alignment: [ + "left top", + "center top", + "right bottom", + ], + }, + }, + }, + files: { + "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwthemetextcolor"), + "bright", + "LWT text color attribute should be set" + ); + + let toolboxCS = window.getComputedStyle(toolbox); + let rootCS = window.getComputedStyle(docEl); + let rootBgImage = rootCS.backgroundImage.split(",")[0].trim(); + let bgImage = toolboxCS.backgroundImage.split(",")[0].trim(); + Assert.ok( + rootBgImage.includes("face1.png"), + `The backgroundImage should use face1.png. Actual value is: ${rootBgImage}` + ); + Assert.equal( + toolboxCS.backgroundImage, + Array(3) + .fill(bgImage) + .join(", "), + "The backgroundImage should use face2.png three times." + ); + Assert.equal( + toolboxCS.backgroundPosition, + "0% 0%, 50% 0%, 100% 100%", + "The backgroundPosition should use the three values provided." + ); + Assert.equal( + toolboxCS.backgroundRepeat, + "no-repeat", + "The backgroundPosition should use the default value." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); + toolboxCS = window.getComputedStyle(toolbox); + + // Styles should've reverted to their initial values. + Assert.equal(rootCS.backgroundImage, "none"); + Assert.equal(rootCS.backgroundPosition, "0% 0%"); + Assert.equal(rootCS.backgroundRepeat, "repeat"); + Assert.equal(toolboxCS.backgroundImage, "none"); + Assert.equal(toolboxCS.backgroundPosition, "0% 0%"); + Assert.equal(toolboxCS.backgroundRepeat, "repeat"); +}); + +add_task(async function test_support_backgrounds_repeat() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face0.png", + additional_backgrounds: ["face1.png", "face2.png", "face3.png"], + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + }, + properties: { + additional_backgrounds_tiling: ["repeat-x", "repeat-y", "repeat"], + }, + }, + }, + files: { + "face0.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face1.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face2.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + "face3.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwthemetextcolor"), + "bright", + "LWT text color attribute should be set" + ); + + let rootCS = window.getComputedStyle(docEl); + let toolboxCS = window.getComputedStyle(toolbox); + let bgImage = rootCS.backgroundImage.split(",")[0].trim(); + Assert.ok( + bgImage.includes("face0.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}` + ); + Assert.equal( + [1, 2, 3].map(num => bgImage.replace(/face[\d]*/, `face${num}`)).join(", "), + toolboxCS.backgroundImage, + "The backgroundImage should use face.png three times." + ); + Assert.equal( + rootCS.backgroundPosition, + "100% 0%", + "The backgroundPosition should use the default value for root." + ); + Assert.equal( + toolboxCS.backgroundPosition, + "100% 0%", + "The backgroundPosition should use the default value for navigator-toolbox." + ); + Assert.equal( + rootCS.backgroundRepeat, + "no-repeat", + "The backgroundRepeat should use the default values for root." + ); + Assert.equal( + toolboxCS.backgroundRepeat, + "repeat-x, repeat-y, repeat", + "The backgroundRepeat should use the three values provided for navigator-toolbox." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); + +add_task(async function test_additional_images_check() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "face.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + }, + properties: { + additional_backgrounds_tiling: ["repeat-x", "repeat-y", "repeat"], + }, + }, + }, + files: { + "face.png": imageBufferFromDataURI(ENCODED_IMAGE_DATA), + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let toolbox = document.querySelector("#navigator-toolbox"); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwthemetextcolor"), + "bright", + "LWT text color attribute should be set" + ); + + let rootCS = window.getComputedStyle(docEl); + let toolboxCS = window.getComputedStyle(toolbox); + let bgImage = rootCS.backgroundImage.split(",")[0]; + Assert.ok( + bgImage.includes("face.png"), + `The backgroundImage should use face.png. Actual value is: ${bgImage}` + ); + Assert.equal( + "none", + toolboxCS.backgroundImage, + "The backgroundImage should not use face.png." + ); + Assert.equal( + rootCS.backgroundPosition, + "100% 0%", + "The backgroundPosition should use the default value." + ); + Assert.equal( + rootCS.backgroundRepeat, + "no-repeat", + "The backgroundPosition should use only one (default) value." + ); + + await extension.unload(); + + Assert.ok(!docEl.hasAttribute("lwtheme"), "LWT attribute should not be set"); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js new file mode 100644 index 0000000000..3e5d789709 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors.js @@ -0,0 +1,157 @@ +"use strict"; + +// This test checks whether the new tab page color properties work. + +/** + * Test whether the selected browser has the new tab page theme applied + * @param {Object} theme that is applied + * @param {boolean} isBrightText whether the brighttext attribute should be set + */ +async function test_ntp_theme(theme, isBrightText) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme, + }, + }); + + let browser = gBrowser.selectedBrowser; + + let { originalBackground, originalColor } = await SpecialPowers.spawn( + browser, + [], + function() { + let doc = content.document; + ok( + !doc.body.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.body.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + return { + originalBackground: content.getComputedStyle(doc.body).backgroundColor, + originalColor: content.getComputedStyle( + doc.querySelector(".outer-wrapper") + ).color, + }; + } + ); + + await extension.startup(); + + Services.ppmm.sharedData.flush(); + + await SpecialPowers.spawn( + browser, + [ + { + isBrightText, + background: hexToCSS(theme.colors.ntp_background), + color: hexToCSS(theme.colors.ntp_text), + }, + ], + function({ isBrightText, background, color }) { + let doc = content.document; + ok( + doc.body.hasAttribute("lwt-newtab"), + "New tab page should have lwt-newtab attribute" + ); + is( + doc.body.hasAttribute("lwt-newtab-brighttext"), + isBrightText, + `New tab page should${ + !isBrightText ? " not" : "" + } have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be set." + ); + } + ); + + await extension.unload(); + + Services.ppmm.sharedData.flush(); + + await SpecialPowers.spawn( + browser, + [ + { + originalBackground, + originalColor, + }, + ], + function({ originalBackground, originalColor }) { + let doc = content.document; + ok( + !doc.body.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.body.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + originalBackground, + "New tab page background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + originalColor, + "New tab page text color should be reset." + ); + } + ); +} + +add_task(async function test_support_ntp_colors() { + // BrowserTestUtils.withNewTab waits for about:newtab to load + // so we disable preloading before running the test. + SpecialPowers.setBoolPref("browser.newtab.preload", false); + registerCleanupFunction(() => { + SpecialPowers.clearUserPref("browser.newtab.preload"); + }); + NewTabPagePreloading.removePreloadedBrowser(window); + for (let url of ["about:newtab", "about:home", "about:welcome"]) { + info("Opening url: " + url); + await BrowserTestUtils.withNewTab({ gBrowser, url }, async browser => { + await test_ntp_theme( + { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + ntp_background: "#add8e6", + ntp_text: "#00008b", + }, + }, + false, + url + ); + + await test_ntp_theme( + { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + ntp_background: "#00008b", + ntp_text: "#add8e6", + }, + }, + true, + url + ); + }); + } +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js new file mode 100644 index 0000000000..bf204632ec --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_ntp_colors_perwindow.js @@ -0,0 +1,249 @@ +"use strict"; + +// This test checks whether the new tab page color properties work per-window. + +/** + * Test whether a given browser has the new tab page theme applied + * @param {Object} browser to test against + * @param {Object} theme that is applied + * @param {boolean} isBrightText whether the brighttext attribute should be set + * @returns {Promise} The task as a promise + */ +function test_ntp_theme(browser, theme, isBrightText) { + Services.ppmm.sharedData.flush(); + return SpecialPowers.spawn( + browser, + [ + { + isBrightText, + background: hexToCSS(theme.colors.ntp_background), + color: hexToCSS(theme.colors.ntp_text), + }, + ], + function({ isBrightText, background, color }) { + let doc = content.document; + ok( + doc.body.hasAttribute("lwt-newtab"), + "New tab page should have lwt-newtab attribute" + ); + is( + doc.body.hasAttribute("lwt-newtab-brighttext"), + isBrightText, + `New tab page should${ + !isBrightText ? " not" : "" + } have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be set." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be set." + ); + } + ); +} + +/** + * Test whether a given browser has the default theme applied + * @param {Object} browser to test against + * @param {string} url being tested + * @returns {Promise} The task as a promise + */ +function test_ntp_default_theme(browser, url) { + Services.ppmm.sharedData.flush(); + if (url === "about:welcome") { + return SpecialPowers.spawn( + browser, + [ + { + background: hexToCSS("#EDEDF0"), + color: hexToCSS("#0C0C0D"), + }, + ], + function({ background, color }) { + let doc = content.document; + ok( + !doc.body.hasAttribute("lwt-newtab"), + "About:welcome page should not have lwt-newtab attribute" + ); + ok( + !doc.body.hasAttribute("lwt-newtab-brighttext"), + `About:welcome page should not have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "About:welcome page background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "About:welcome page text color should be reset." + ); + } + ); + } + return SpecialPowers.spawn( + browser, + [ + { + background: hexToCSS("#F9F9FA"), + color: hexToCSS("#0C0C0D"), + }, + ], + function({ background, color }) { + let doc = content.document; + ok( + !doc.body.hasAttribute("lwt-newtab"), + "New tab page should not have lwt-newtab attribute" + ); + ok( + !doc.body.hasAttribute("lwt-newtab-brighttext"), + `New tab page should not have lwt-newtab-brighttext attribute` + ); + + is( + content.getComputedStyle(doc.body).backgroundColor, + background, + "New tab page background should be reset." + ); + is( + content.getComputedStyle(doc.querySelector(".outer-wrapper")).color, + color, + "New tab page text color should be reset." + ); + } + ); +} + +add_task(async function test_per_window_ntp_theme() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + function promiseWindowChecked() { + return new Promise(resolve => { + let listener = msg => { + if (msg == "checked-window") { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }; + browser.test.onMessage.addListener(listener); + }); + } + + function removeWindow(winId) { + return new Promise(resolve => { + let listener = removedWinId => { + if (removedWinId == winId) { + browser.windows.onRemoved.removeListener(listener); + resolve(); + } + }; + browser.windows.onRemoved.addListener(listener); + browser.windows.remove(winId); + }); + } + + async function checkWindow(theme, isBrightText, winId) { + let windowChecked = promiseWindowChecked(); + browser.test.sendMessage("check-window", { + theme, + isBrightText, + winId, + }); + await windowChecked; + } + + const darkTextTheme = { + colors: { + frame: "#add8e6", + tab_background_text: "#000", + ntp_background: "#add8e6", + ntp_text: "#000", + }, + }; + + const brightTextTheme = { + colors: { + frame: "#00008b", + tab_background_text: "#add8e6", + ntp_background: "#00008b", + ntp_text: "#add8e6", + }, + }; + + let { id: winId } = await browser.windows.getCurrent(); + // We are opening about:blank instead of the default homepage, + // because using the default homepage results in intermittent + // test failures on debug builds due to browser window leaks. + let { id: secondWinId } = await browser.windows.create({ + url: "about:blank", + }); + + browser.test.log("Test that single window update works"); + await browser.theme.update(winId, darkTextTheme); + await checkWindow(darkTextTheme, false, winId); + await checkWindow(null, false, secondWinId); + + browser.test.log("Test that applying different themes on both windows"); + await browser.theme.update(secondWinId, brightTextTheme); + await checkWindow(darkTextTheme, false, winId); + await checkWindow(brightTextTheme, true, secondWinId); + + browser.test.log("Test resetting the theme on one window"); + await browser.theme.reset(winId); + await checkWindow(null, false, winId); + await checkWindow(brightTextTheme, true, secondWinId); + + await removeWindow(secondWinId); + await checkWindow(null, false, winId); + browser.test.notifyPass("perwindow-ntp-theme"); + }, + }); + + extension.onMessage( + "check-window", + async ({ theme, isBrightText, winId }) => { + let win = Services.wm.getOuterWindowWithId(winId); + win.NewTabPagePreloading.removePreloadedBrowser(win); + // These pages were initially chosen because LightweightThemeChild.jsm + // treats them specially. + for (let url of ["about:newtab", "about:home", "about:welcome"]) { + info("Opening url: " + url); + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url }, + async browser => { + if (theme) { + await test_ntp_theme(browser, theme, isBrightText); + } else { + await test_ntp_default_theme(browser, url); + } + } + ); + } + extension.sendMessage("checked-window"); + } + ); + + // BrowserTestUtils.withNewTab waits for about:newtab to load + // so we disable preloading before running the test. + await SpecialPowers.setBoolPref("browser.newtab.preload", false); + await SpecialPowers.setBoolPref("browser.aboutwelcome.enabled", true); + registerCleanupFunction(() => { + SpecialPowers.clearUserPref("browser.newtab.preload"); + SpecialPowers.clearUserPref("browser.aboutwelcome.enabled"); + }); + + await extension.startup(); + await extension.awaitFinish("perwindow-ntp-theme"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js new file mode 100644 index 0000000000..bc72609acd --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_persistence.js @@ -0,0 +1,58 @@ +"use strict"; + +// This test checks whether applied WebExtension themes are persisted and applied +// on newly opened windows. + +add_task(async function test_multiple_windows() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let docEl = window.document.documentElement; + let style = window.getComputedStyle(docEl); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwthemetextcolor"), + "bright", + "LWT text color attribute should be set" + ); + Assert.ok( + style.backgroundImage.includes("image1.png"), + "Expected background image" + ); + + // Now we'll open a new window to see if the theme is also applied there. + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + docEl = window2.document.documentElement; + style = window2.getComputedStyle(docEl); + + Assert.ok(docEl.hasAttribute("lwtheme"), "LWT attribute should be set"); + Assert.equal( + docEl.getAttribute("lwthemetextcolor"), + "bright", + "LWT text color attribute should be set" + ); + Assert.ok( + style.backgroundImage.includes("image1.png"), + "Expected background image" + ); + + await BrowserTestUtils.closeWindow(window2); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js b/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js new file mode 100644 index 0000000000..d8b3b14073 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_reset.js @@ -0,0 +1,112 @@ +"use strict"; + +add_task(async function theme_reset_global_static_theme() { + let global_theme_extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#123456", + tab_background_text: "#fedcba", + }, + }, + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + await browser.theme.reset(); + let theme_after_reset = await browser.theme.getCurrent(); + + browser.test.assertEq( + "#123456", + theme_after_reset.colors.frame, + "Theme from other extension should not be cleared upon reset()" + ); + + let theme = { + colors: { + frame: "#CF723F", + }, + }; + + await browser.theme.update(theme); + await browser.theme.reset(); + let final_reset_theme = await browser.theme.getCurrent(); + + browser.test.assertEq( + JSON.stringify({ colors: null, images: null, properties: null }), + JSON.stringify(final_reset_theme), + "Should reset when extension had replaced the global theme" + ); + browser.test.sendMessage("done"); + }, + }); + await global_theme_extension.startup(); + await extension.startup(); + await extension.awaitMessage("done"); + + await global_theme_extension.unload(); + await extension.unload(); +}); + +add_task(async function theme_reset_by_windowId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + async background() { + let theme = { + colors: { + frame: "#CF723F", + }, + }; + + let { id: winId } = await browser.windows.getCurrent(); + await browser.theme.update(winId, theme); + let update_theme = await browser.theme.getCurrent(winId); + + browser.test.onMessage.addListener(async () => { + let current_theme = await browser.theme.getCurrent(winId); + browser.test.assertEq( + update_theme.colors.frame, + current_theme.colors.frame, + "Should not be reset by a reset(windowId) call from another extension" + ); + + browser.test.sendMessage("done"); + }); + + browser.test.sendMessage("ready", winId); + }, + }); + + let anotherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener(async winId => { + await browser.theme.reset(winId); + browser.test.sendMessage("done"); + }); + }, + }); + + await extension.startup(); + let winId = await extension.awaitMessage("ready"); + + await anotherExtension.startup(); + + // theme.reset should be ignored if the theme was set by another extension. + anotherExtension.sendMessage(winId); + await anotherExtension.awaitMessage("done"); + + extension.sendMessage(); + await extension.awaitMessage("done"); + + await anotherExtension.unload(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js new file mode 100644 index 0000000000..36658cd5b4 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sanitization.js @@ -0,0 +1,175 @@ +"use strict"; + +// This test checks color sanitization in various situations + +add_task(async function test_sanitization_invalid() { + // This test checks that invalid values are sanitized + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "ntimsfavoriteblue", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 0, 0)", + "All invalid values should always compute to black." + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_css_variables() { + // This test checks that CSS variables are sanitized + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "var(--arrowpanel-dimmed)", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 0, 0)", + "All CSS variables should always compute to black." + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_important() { + // This test checks that the sanitizer cannot be fooled with !important + let stylesheetAttr = `href="data:text/css,*{color:red!important}" type="text/css"`; + let stylesheet = document.createProcessingInstruction( + "xml-stylesheet", + stylesheetAttr + ); + let load = BrowserTestUtils.waitForEvent(stylesheet, "load"); + document.insertBefore(stylesheet, document.documentElement); + await load; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + bookmark_text: "green", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(255, 0, 0)", + "Sheet applies" + ); + + stylesheet.remove(); + + Assert.equal( + window.getComputedStyle(navbar).color, + "rgb(0, 128, 0)", + "Shouldn't be able to fool the color sanitizer with !important" + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_transparent() { + // This test checks whether transparent values are applied properly + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_top_separator: "transparent", + }, + }, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.ok( + window.getComputedStyle(navbar).boxShadow.includes("rgba(0, 0, 0, 0)"), + "Top separator should be transparent" + ); + + await extension.unload(); +}); + +add_task(async function test_sanitization_transparent_frame_color() { + // This test checks whether transparent frame color falls back to white. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "transparent", + tab_background_text: TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + let docEl = document.documentElement; + Assert.equal( + window.getComputedStyle(docEl).backgroundColor, + "rgb(255, 255, 255)", + "Accent color should be white" + ); + + await extension.unload(); +}); + +add_task( + async function test_sanitization_transparent_tab_background_text_color() { + // This test checks whether transparent textcolor falls back to black. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: "transparent", + }, + }, + }, + }); + + await extension.startup(); + + let docEl = document.documentElement; + Assert.equal( + window.getComputedStyle(docEl).color, + "rgb(0, 0, 0)", + "Text color should be black" + ); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js new file mode 100644 index 0000000000..4266a982d8 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_separators.js @@ -0,0 +1,69 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the separator colors are applied properly. + +add_task(async function test_support_separator_properties() { + const SEPARATOR_TOP_COLOR = "#ff00ff"; + const SEPARATOR_VERTICAL_COLOR = "#f0000f"; + const SEPARATOR_FIELD_COLOR = "#9400ff"; + const SEPARATOR_BOTTOM_COLOR = "#3366cc"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_top_separator: SEPARATOR_TOP_COLOR, + toolbar_vertical_separator: SEPARATOR_VERTICAL_COLOR, + toolbar_field_separator: SEPARATOR_FIELD_COLOR, + toolbar_bottom_separator: SEPARATOR_BOTTOM_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let navbar = document.querySelector("#nav-bar"); + Assert.ok( + window + .getComputedStyle(navbar) + .boxShadow.includes(`rgb(${hexToRGB(SEPARATOR_TOP_COLOR).join(", ")})`), + "Top separator color properly set" + ); + + let mainWin = document.querySelector("#main-window"); + Assert.equal( + window + .getComputedStyle(mainWin) + .getPropertyValue("--urlbar-separator-color"), + `rgb(${hexToRGB(SEPARATOR_FIELD_COLOR).join(", ")})`, + "Toolbar field separator color properly set" + ); + + let panelUIButton = document.querySelector("#PanelUI-button"); + Assert.ok( + window + .getComputedStyle(panelUIButton) + .getPropertyValue("border-image-source") + .includes(`rgb(${hexToRGB(SEPARATOR_VERTICAL_COLOR).join(", ")})`), + "Vertical separator color properly set" + ); + + let toolbox = document.querySelector("#navigator-toolbox"); + Assert.equal( + window.getComputedStyle(toolbox).borderBottomColor, + `rgb(${hexToRGB(SEPARATOR_BOTTOM_COLOR).join(", ")})`, + "Bottom separator color properly set" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js new file mode 100644 index 0000000000..3d814d1082 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_sidebars.js @@ -0,0 +1,274 @@ +"use strict"; + +// This test checks whether the sidebar color properties work. + +/** + * Test whether the selected browser has the sidebar theme applied + * @param {Object} theme that is applied + * @param {boolean} isBrightText whether the brighttext attribute should be set + */ +async function test_sidebar_theme(theme, isBrightText) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme, + }, + }); + + const sidebarBox = document.getElementById("sidebar-box"); + const content = SidebarUI.browser.contentWindow; + const root = content.document.documentElement; + + ok( + !sidebarBox.hasAttribute("lwt-sidebar"), + "Sidebar box should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar"), + "Sidebar should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar-brighttext"), + "Sidebar should not have lwt-sidebar-brighttext attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar-highlight"), + "Sidebar should not have lwt-sidebar-highlight attribute" + ); + + const rootCS = content.getComputedStyle(root); + const originalBackground = rootCS.backgroundColor; + const originalColor = rootCS.color; + + // ::-moz-tree-row(selected, focus) computed style can't be accessed, so we create a fake one. + const highlightCS = { + get backgroundColor() { + // Standardize to rgb like other computed style. + let color = rootCS.getPropertyValue( + "--lwt-sidebar-highlight-background-color" + ); + let [r, g, b] = color + .replace("rgba(", "") + .split(",") + .map(channel => parseInt(channel, 10)); + return `rgb(${r}, ${g}, ${b})`; + }, + + get color() { + let color = rootCS.getPropertyValue("--lwt-sidebar-highlight-text-color"); + let [r, g, b] = color + .replace("rgba(", "") + .split(",") + .map(channel => parseInt(channel, 10)); + return `rgb(${r}, ${g}, ${b})`; + }, + }; + const originalHighlightBackground = highlightCS.backgroundColor; + const originalHighlightColor = highlightCS.color; + + await extension.startup(); + + Services.ppmm.sharedData.flush(); + + const actualBackground = hexToCSS(theme.colors.sidebar) || originalBackground; + const actualColor = hexToCSS(theme.colors.sidebar_text) || originalColor; + const actualHighlightBackground = + hexToCSS(theme.colors.sidebar_highlight) || originalHighlightBackground; + const actualHighlightColor = + hexToCSS(theme.colors.sidebar_highlight_text) || originalHighlightColor; + const isCustomHighlight = !!theme.colors.sidebar_highlight_text; + const isCustomSidebar = !!theme.colors.sidebar_text; + + is( + sidebarBox.hasAttribute("lwt-sidebar"), + isCustomSidebar, + `Sidebar box should${ + !isCustomSidebar ? " not" : "" + } have lwt-sidebar attribute` + ); + is( + root.hasAttribute("lwt-sidebar"), + isCustomSidebar, + `Sidebar should${!isCustomSidebar ? " not" : ""} have lwt-sidebar attribute` + ); + is( + root.hasAttribute("lwt-sidebar-brighttext"), + isBrightText, + `Sidebar should${ + !isBrightText ? " not" : "" + } have lwt-sidebar-brighttext attribute` + ); + is( + root.hasAttribute("lwt-sidebar-highlight"), + isCustomHighlight, + `Sidebar should${ + !isCustomHighlight ? " not" : "" + } have lwt-sidebar-highlight attribute` + ); + + if (isCustomSidebar) { + const sidebarBoxCS = window.getComputedStyle(sidebarBox); + is( + sidebarBoxCS.backgroundColor, + actualBackground, + "Sidebar box background should be set." + ); + is( + sidebarBoxCS.color, + actualColor, + "Sidebar box text color should be set." + ); + } + + is( + rootCS.backgroundColor, + actualBackground, + "Sidebar background should be set." + ); + is(rootCS.color, actualColor, "Sidebar text color should be set."); + + is( + highlightCS.backgroundColor, + actualHighlightBackground, + "Sidebar highlight background color should be set." + ); + is( + highlightCS.color, + actualHighlightColor, + "Sidebar highlight text color should be set." + ); + + await extension.unload(); + + Services.ppmm.sharedData.flush(); + + ok( + !sidebarBox.hasAttribute("lwt-sidebar"), + "Sidebar box should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar"), + "Sidebar should not have lwt-sidebar attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar-brighttext"), + "Sidebar should not have lwt-sidebar-brighttext attribute" + ); + ok( + !root.hasAttribute("lwt-sidebar-highlight"), + "Sidebar should not have lwt-sidebar-highlight attribute" + ); + + is( + rootCS.backgroundColor, + originalBackground, + "Sidebar background should be reset." + ); + is(rootCS.color, originalColor, "Sidebar text color should be reset."); + is( + highlightCS.backgroundColor, + originalHighlightBackground, + "Sidebar highlight background color should be reset." + ); + is( + highlightCS.color, + originalHighlightColor, + "Sidebar highlight text color should be reset." + ); +} + +add_task(async function test_support_sidebar_colors() { + for (let command of ["viewBookmarksSidebar", "viewHistorySidebar"]) { + info("Executing command: " + command); + + await SidebarUI.show(command); + + await test_sidebar_theme( + { + colors: { + sidebar: "#fafad2", // lightgoldenrodyellow + sidebar_text: "#2f4f4f", // darkslategrey + }, + }, + false + ); + + await test_sidebar_theme( + { + colors: { + sidebar: "#8b4513", // saddlebrown + sidebar_text: "#ffa07a", // lightsalmon + }, + }, + true + ); + + await test_sidebar_theme( + { + colors: { + sidebar: "#fffafa", // snow + sidebar_text: "#663399", // rebeccapurple + sidebar_highlight: "#7cfc00", // lawngreen + sidebar_highlight_text: "#ffefd5", // papayawhip + }, + }, + false + ); + + await test_sidebar_theme( + { + colors: { + sidebar_highlight: "#a0522d", // sienna + sidebar_highlight_text: "#fff5ee", // seashell + }, + }, + false + ); + } +}); + +add_task(async function test_support_sidebar_border_color() { + const LIGHT_SALMON = "#ffa07a"; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + sidebar_border: LIGHT_SALMON, + }, + }, + }, + }); + + await extension.startup(); + + const sidebarHeader = document.getElementById("sidebar-header"); + const sidebarHeaderCS = window.getComputedStyle(sidebarHeader); + + is( + sidebarHeaderCS.borderBottomColor, + hexToCSS(LIGHT_SALMON), + "Sidebar header border should be colored properly" + ); + + if (AppConstants.platform !== "linux") { + const sidebarSplitter = document.getElementById("sidebar-splitter"); + const sidebarSplitterCS = window.getComputedStyle(sidebarSplitter); + + is( + sidebarSplitterCS.borderInlineEndColor, + hexToCSS(LIGHT_SALMON), + "Sidebar splitter should be colored properly" + ); + + SidebarUI.reversePosition(); + + is( + sidebarSplitterCS.borderInlineStartColor, + hexToCSS(LIGHT_SALMON), + "Sidebar splitter should be colored properly after switching sides" + ); + + SidebarUI.reversePosition(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js new file mode 100644 index 0000000000..081322faa3 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_static_onUpdated.js @@ -0,0 +1,66 @@ +"use strict"; + +// This test checks whether browser.theme.onUpdated works +// when a static theme is applied + +add_task(async function test_on_updated() { + const theme = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.theme.onUpdated.addListener(updateInfo => { + browser.test.sendMessage("theme-updated", updateInfo); + }); + }, + }); + + await extension.startup(); + + info("Testing update event on static theme startup"); + let updatedPromise = extension.awaitMessage("theme-updated"); + await theme.startup(); + const { theme: receivedTheme, windowId } = await updatedPromise; + Assert.ok(!windowId, "No window id in static theme update event"); + Assert.ok( + receivedTheme.images.theme_frame.includes("image1.png"), + "Theme theme_frame image should be applied" + ); + Assert.equal( + receivedTheme.colors.frame, + ACCENT_COLOR, + "Theme frame color should be applied" + ); + Assert.equal( + receivedTheme.colors.tab_background_text, + TEXT_COLOR, + "Theme tab_background_text color should be applied" + ); + + info("Testing update event on static theme unload"); + updatedPromise = extension.awaitMessage("theme-updated"); + await theme.unload(); + const updateInfo = await updatedPromise; + Assert.ok(!windowId, "No window id in static theme update event on unload"); + Assert.equal( + Object.keys(updateInfo.theme), + 0, + "unloading theme sends empty theme in update event" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js new file mode 100644 index 0000000000..928fa4edee --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_line.js @@ -0,0 +1,50 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the color of the tab line are applied properly. + +add_task(async function test_support_tab_line() { + for (let protonTabsEnabled of [true, false]) { + SpecialPowers.pushPrefEnv({ + set: [["browser.proton.tabs.enabled", protonTabsEnabled]], + }); + let newWin = await BrowserTestUtils.openNewWindowWithFlushedXULCacheForMozSupports(); + + const TAB_LINE_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + tab_line: TAB_LINE_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking selected tab line color"); + let selectedTab = newWin.document.querySelector( + ".tabbrowser-tab[selected]" + ); + let line = selectedTab.querySelector(".tab-line"); + if (protonTabsEnabled) { + Assert.equal( + newWin.getComputedStyle(line).display, + "none", + "Tab line should not be displayed when Proton is enabled" + ); + } else { + Assert.equal( + newWin.getComputedStyle(line).backgroundColor, + `rgb(${hexToRGB(TAB_LINE_COLOR).join(", ")})`, + "Tab line should have theme color" + ); + } + + await extension.unload(); + await BrowserTestUtils.closeWindow(newWin); + } +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js new file mode 100644 index 0000000000..1e402dbcc6 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_loading.js @@ -0,0 +1,51 @@ +"use strict"; + +add_task(async function test_support_tab_loading_filling() { + const TAB_LOADING_COLOR = "#FF0000"; + + // Make sure we use the animating loading icon + await SpecialPowers.pushPrefEnv({ + set: [["ui.prefersReducedMotion", 0]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: "#000", + toolbar: "#124455", + tab_background_text: "#9400ff", + tab_loading: TAB_LOADING_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab loading indicator colors"); + + let selectedTab = document.querySelector( + ".tabbrowser-tab[visuallyselected=true]" + ); + + selectedTab.setAttribute("busy", "true"); + selectedTab.setAttribute("progress", "true"); + + let throbber = selectedTab.throbber; + Assert.equal( + window.getComputedStyle(throbber, "::before").fill, + `rgb(${hexToRGB(TAB_LOADING_COLOR).join(", ")})`, + "Throbber is filled with theme color" + ); + + selectedTab.removeAttribute("busy"); + selectedTab.removeAttribute("progress"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js new file mode 100644 index 0000000000..21f3c6d38b --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_selected.js @@ -0,0 +1,54 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color of selected tab are applied correctly. + +add_task(async function test_tab_background_color_property() { + const TAB_BACKGROUND_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + tab_selected: TAB_BACKGROUND_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking selected tab color"); + + let openTab = document.querySelector( + ".tabbrowser-tab[visuallyselected=true]" + ); + let openTabBackground = openTab.querySelector(".tab-background"); + + let selectedTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + let selectedTabBackground = selectedTab.querySelector(".tab-background"); + + let openTabGradient = window + .getComputedStyle(openTabBackground) + .getPropertyValue("background-image"); + let selectedTabGradient = window + .getComputedStyle(selectedTabBackground) + .getPropertyValue("background-image"); + + let rgbRegex = /rgb\((\d{1,3}), (\d{1,3}), (\d{1,3})\)/g; + let selectedTabColors = selectedTabGradient.match(rgbRegex); + + Assert.equal( + selectedTabColors[0], + "rgb(" + hexToRGB(TAB_BACKGROUND_COLOR).join(", ") + ")", + "Selected tab background color should be set." + ); + Assert.equal(openTabGradient, "none"); + + gBrowser.removeTab(selectedTab); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_separators.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_separators.js new file mode 100644 index 0000000000..722c7dd99c --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_separators.js @@ -0,0 +1,38 @@ +"use strict"; + +add_task(async function test_support_tab_separators() { + const TAB_SEPARATOR_COLOR = "#FF0000"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#000", + tab_background_text: "#9400ff", + tab_background_separator: TAB_SEPARATOR_COLOR, + }, + }, + }, + }); + await extension.startup(); + + info("Checking background tab separator color"); + + let tab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + + Assert.equal( + window.getComputedStyle(tab, "::before").borderLeftColor, + `rgb(${hexToRGB(TAB_SEPARATOR_COLOR).join(", ")})`, + "Left separator has right color." + ); + + Assert.equal( + window.getComputedStyle(tab, "::after").borderLeftColor, + `rgb(${hexToRGB(TAB_SEPARATOR_COLOR).join(", ")})`, + "Right separator has right color." + ); + + gBrowser.removeTab(tab); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js new file mode 100644 index 0000000000..d819f3a5f1 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_tab_text.js @@ -0,0 +1,70 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the text color of the selected tab are applied properly. + +add_task(async function test_support_tab_text_property_css_color() { + const TAB_TEXT_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + tab_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + "rgb(" + hexToRGB(TAB_TEXT_COLOR).join(", ") + ")", + "Selected tab text color should be set." + ); + + await extension.unload(); +}); + +add_task(async function test_support_tab_text_chrome_array() { + const TAB_TEXT_COLOR = [148, 0, 255]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: FRAME_COLOR, + tab_background_text: TAB_BACKGROUND_TEXT_COLOR, + tab_text: TAB_TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + "rgb(" + TAB_TEXT_COLOR.join(", ") + ")", + "Selected tab text color should be set." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js b/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js new file mode 100644 index 0000000000..39934200ac --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_theme_transition.js @@ -0,0 +1,48 @@ +"use strict"; + +// This test checks whether the applied theme transition effects are applied +// correctly. + +add_task(async function test_theme_transition_effects() { + const TOOLBAR = "#f27489"; + const TEXT_COLOR = "#000000"; + const TRANSITION_PROPERTY = "background-color"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR, + bookmark_text: TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + // check transition effect for toolbars + let navbar = document.querySelector("#nav-bar"); + let navbarCS = window.getComputedStyle(navbar); + + Assert.ok( + navbarCS + .getPropertyValue("transition-property") + .includes(TRANSITION_PROPERTY), + "Transition property set for #nav-bar" + ); + + let bookmarksBar = document.querySelector("#PersonalToolbar"); + setToolbarVisibility(bookmarksBar, true, false, true); + let bookmarksBarCS = window.getComputedStyle(bookmarksBar); + + Assert.ok( + bookmarksBarCS + .getPropertyValue("transition-property") + .includes(TRANSITION_PROPERTY), + "Transition property set for #PersonalToolbar" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js new file mode 100644 index 0000000000..cd4d08c38f --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields.js @@ -0,0 +1,145 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color and the color of the navbar text fields are applied properly. + +ChromeUtils.import( + "resource://testing-common/CustomizableUITestUtils.jsm", + this +); +let gCUITestUtils = new CustomizableUITestUtils(window); + +add_task(async function setup() { + await gCUITestUtils.addSearchBar(); + registerCleanupFunction(() => { + gCUITestUtils.removeSearchBar(); + }); +}); + +add_task(async function test_support_toolbar_field_properties() { + const TOOLBAR_FIELD_BACKGROUND = "#ff00ff"; + const TOOLBAR_FIELD_COLOR = "#00ff00"; + const TOOLBAR_FIELD_BORDER = "#aaaaff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: TOOLBAR_FIELD_BACKGROUND, + toolbar_field_text: TOOLBAR_FIELD_COLOR, + toolbar_field_border: TOOLBAR_FIELD_BORDER, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let root = document.documentElement; + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + root.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + root.setAttribute("remotecontrol", "true"); + }); + + let fields = [ + document.querySelector("#urlbar-background"), + BrowserSearch.searchBar, + ].filter(field => { + let bounds = field.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + Assert.equal(fields.length, 2, "Should be testing two elements"); + + info( + `Checking toolbar background colors and colors for ${fields.length} toolbar fields.` + ); + for (let field of fields) { + info(`Testing ${field.id || field.className}`); + Assert.equal( + window.getComputedStyle(field).backgroundColor, + hexToCSS(TOOLBAR_FIELD_BACKGROUND), + "Field background should be set." + ); + Assert.equal( + window.getComputedStyle(field).color, + hexToCSS(TOOLBAR_FIELD_COLOR), + "Field color should be set." + ); + testBorderColor(field, TOOLBAR_FIELD_BORDER); + } + + await extension.unload(); +}); + +add_task(async function test_support_toolbar_field_brighttext() { + let root = document.documentElement; + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + root.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + root.setAttribute("remotecontrol", "true"); + }); + let urlbar = gURLBar.textbox; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: "#fff", + toolbar_field_text: "#000", + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlbar).color, + hexToCSS("#000000"), + "Color has been set" + ); + Assert.ok( + !root.hasAttribute("lwt-toolbar-field-brighttext"), + "Brighttext attribute should not be set" + ); + + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar_field: "#000", + toolbar_field_text: "#fff", + }, + }, + }, + }); + + await extension.startup(); + + Assert.equal( + window.getComputedStyle(urlbar).color, + hexToCSS("#ffffff"), + "Color has been set" + ); + Assert.ok( + root.hasAttribute("lwt-toolbar-field-brighttext"), + "Brighttext attribute should be set" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js new file mode 100644 index 0000000000..05b6a186d2 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbar_fields_focus.js @@ -0,0 +1,102 @@ +"use strict"; + +add_task(async function setup() { + // Remove the `remotecontrol` attribute since it interferes with the urlbar styling. + document.documentElement.removeAttribute("remotecontrol"); + registerCleanupFunction(() => { + document.documentElement.setAttribute("remotecontrol", "true"); + }); +}); + +add_task(async function test_toolbar_field_focus() { + const TOOLBAR_FIELD_BACKGROUND = "#FF00FF"; + const TOOLBAR_FIELD_COLOR = "#00FF00"; + const TOOLBAR_FOCUS_BACKGROUND = "#FF0000"; + const TOOLBAR_FOCUS_TEXT = "#9400FF"; + const TOOLBAR_FOCUS_BORDER = "#FFFFFF"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#FF0000", + tab_background_color: "#ffffff", + toolbar_field: TOOLBAR_FIELD_BACKGROUND, + toolbar_field_text: TOOLBAR_FIELD_COLOR, + toolbar_field_focus: TOOLBAR_FOCUS_BACKGROUND, + toolbar_field_text_focus: TOOLBAR_FOCUS_TEXT, + toolbar_field_border_focus: TOOLBAR_FOCUS_BORDER, + }, + }, + }, + }); + + await extension.startup(); + + info("Checking toolbar field's focus color"); + + let urlBar = document.querySelector("#urlbar-background"); + gURLBar.textbox.setAttribute("focused", "true"); + + Assert.equal( + window.getComputedStyle(urlBar).backgroundColor, + `rgb(${hexToRGB(TOOLBAR_FOCUS_BACKGROUND).join(", ")})`, + "Background Color is changed" + ); + Assert.equal( + window.getComputedStyle(urlBar).color, + `rgb(${hexToRGB(TOOLBAR_FOCUS_TEXT).join(", ")})`, + "Text Color is changed" + ); + testBorderColor(urlBar, TOOLBAR_FOCUS_BORDER); + + gURLBar.textbox.removeAttribute("focused"); + + Assert.equal( + window.getComputedStyle(urlBar).backgroundColor, + `rgb(${hexToRGB(TOOLBAR_FIELD_BACKGROUND).join(", ")})`, + "Background Color is set back to initial" + ); + Assert.equal( + window.getComputedStyle(urlBar).color, + `rgb(${hexToRGB(TOOLBAR_FIELD_COLOR).join(", ")})`, + "Text Color is set back to initial" + ); + await extension.unload(); +}); + +add_task(async function test_toolbar_field_focus_low_alpha() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: "#FF0000", + tab_background_color: "#ffffff", + toolbar_field: "#FF00FF", + toolbar_field_text: "#00FF00", + toolbar_field_focus: "rgba(0, 0, 255, 0.4)", + toolbar_field_text_focus: "red", + toolbar_field_border_focus: "#FFFFFF", + }, + }, + }, + }); + + await extension.startup(); + gURLBar.textbox.setAttribute("focused", "true"); + + let urlBar = document.querySelector("#urlbar-background"); + Assert.equal( + window.getComputedStyle(urlBar).backgroundColor, + `rgba(0, 0, 255, 0.9)`, + "Background color has minimum opacity enforced" + ); + Assert.equal( + window.getComputedStyle(urlBar).color, + `rgb(255, 255, 255)`, + "Text color has been overridden to match background" + ); + + gURLBar.textbox.removeAttribute("focused"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js new file mode 100644 index 0000000000..f31e0fce8a --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_colors.js @@ -0,0 +1,56 @@ +"use strict"; + +/* globals InspectorUtils */ + +// This test checks whether applied WebExtension themes that attempt to change +// the button background color properties are applied correctly. + +add_task(async function test_button_background_properties() { + const BUTTON_BACKGROUND_ACTIVE = "#FFFFFF"; + const BUTTON_BACKGROUND_HOVER = "#59CBE8"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + button_background_active: BUTTON_BACKGROUND_ACTIVE, + button_background_hover: BUTTON_BACKGROUND_HOVER, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarButton = document.querySelector("#home-button"); + let toolbarButtonIcon = toolbarButton.icon; + let toolbarButtonIconCS = window.getComputedStyle(toolbarButtonIcon); + + InspectorUtils.addPseudoClassLock(toolbarButton, ":hover"); + + Assert.equal( + toolbarButtonIconCS.getPropertyValue("background-color"), + `rgb(${hexToRGB(BUTTON_BACKGROUND_HOVER).join(", ")})`, + "Toolbar button hover background is set." + ); + + InspectorUtils.addPseudoClassLock(toolbarButton, ":active"); + + Assert.equal( + toolbarButtonIconCS.getPropertyValue("background-color"), + `rgb(${hexToRGB(BUTTON_BACKGROUND_ACTIVE).join(", ")})`, + "Toolbar button active background is set!" + ); + + InspectorUtils.clearPseudoClassLocks(toolbarButton); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js new file mode 100644 index 0000000000..11643412dd --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbarbutton_icons.js @@ -0,0 +1,107 @@ +"use strict"; + +// This test checks applied WebExtension themes that attempt to change +// icon color properties + +add_task(async function test_icons_properties() { + const ICONS_COLOR = "#001b47"; + const ICONS_ATTENTION_COLOR = "#44ba77"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + icons: ICONS_COLOR, + icons_attention: ICONS_ATTENTION_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarbutton = document.querySelector("#home-button"); + Assert.equal( + window.getComputedStyle(toolbarbutton).getPropertyValue("fill"), + `rgb(${hexToRGB(ICONS_COLOR).join(", ")})`, + "Buttons fill color set!" + ); + + let starButton = document.querySelector("#star-button"); + starButton.setAttribute("starred", "true"); + + let starComputedStyle = window.getComputedStyle(starButton); + Assert.equal( + starComputedStyle.getPropertyValue( + "--lwt-toolbarbutton-icon-fill-attention" + ), + `rgb(${hexToRGB(ICONS_ATTENTION_COLOR).join(", ")})`, + "Variable is properly set" + ); + Assert.equal( + starComputedStyle.getPropertyValue("fill"), + `rgb(${hexToRGB(ICONS_ATTENTION_COLOR).join(", ")})`, + "Starred icon fill is properly set" + ); + + starButton.removeAttribute("starred"); + + await extension.unload(); +}); + +add_task(async function test_no_icons_properties() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + images: { + theme_frame: "image1.png", + }, + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + }, + }, + }, + files: { + "image1.png": BACKGROUND, + }, + }); + + await extension.startup(); + + let toolbarbutton = document.querySelector("#home-button"); + let toolbarbuttonCS = window.getComputedStyle(toolbarbutton); + Assert.equal( + toolbarbuttonCS.getPropertyValue("--lwt-toolbarbutton-icon-fill"), + "", + "Icon fill should not be set when the value is not specified in the manifest." + ); + let currentColor = toolbarbuttonCS.getPropertyValue("color"); + Assert.equal( + window.getComputedStyle(toolbarbutton).getPropertyValue("fill"), + currentColor, + "Button fill color should be currentColor when no icon color specified." + ); + + let starButton = document.querySelector("#star-button"); + starButton.setAttribute("starred", "true"); + let starComputedStyle = window.getComputedStyle(starButton); + Assert.equal( + starComputedStyle.getPropertyValue( + "--lwt-toolbarbutton-icon-fill-attention" + ), + "", + "Icon attention fill should not be set when the value is not specified in the manifest." + ); + starButton.removeAttribute("starred"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js new file mode 100644 index 0000000000..ee31d80888 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_toolbars.js @@ -0,0 +1,105 @@ +"use strict"; + +// This test checks whether applied WebExtension themes that attempt to change +// the background color of toolbars are applied properly. + +add_task(async function test_support_toolbar_property() { + const TOOLBAR_COLOR = "#ff00ff"; + const TOOLBAR_TEXT_COLOR = "#9400ff"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + toolbar_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolbars = [ + ...toolbox.querySelectorAll("toolbar:not(#TabsToolbar)"), + ].filter(toolbar => { + let bounds = toolbar.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + let transitionPromise = waitForTransition(toolbars[0], "background-color"); + await extension.startup(); + await transitionPromise; + + info(`Checking toolbar colors for ${toolbars.length} toolbars.`); + for (let toolbar of toolbars) { + info(`Testing ${toolbar.id}`); + Assert.equal( + window.getComputedStyle(toolbar).backgroundColor, + hexToCSS(TOOLBAR_COLOR), + "Toolbar background color should be set." + ); + Assert.equal( + window.getComputedStyle(toolbar).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Toolbar text color should be set." + ); + } + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + hexToCSS(TOOLBAR_TEXT_COLOR), + "Selected tab text color should be set." + ); + + await extension.unload(); +}); + +add_task(async function test_bookmark_text_property() { + const TOOLBAR_COLOR = [255, 0, 255]; + const TOOLBAR_TEXT_COLOR = [48, 0, 255]; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: { + frame: ACCENT_COLOR, + tab_background_text: TEXT_COLOR, + toolbar: TOOLBAR_COLOR, + bookmark_text: TOOLBAR_TEXT_COLOR, + }, + }, + }, + }); + + await extension.startup(); + + let toolbox = document.querySelector("#navigator-toolbox"); + let toolbars = [ + ...toolbox.querySelectorAll("toolbar:not(#TabsToolbar)"), + ].filter(toolbar => { + let bounds = toolbar.getBoundingClientRect(); + return bounds.width > 0 && bounds.height > 0; + }); + + info(`Checking toolbar colors for ${toolbars.length} toolbars.`); + for (let toolbar of toolbars) { + info(`Testing ${toolbar.id}`); + Assert.equal( + window.getComputedStyle(toolbar).color, + rgbToCSS(TOOLBAR_TEXT_COLOR), + "bookmark_text should be an alias for toolbar_text" + ); + } + + info("Checking selected tab colors"); + let selectedTab = document.querySelector(".tabbrowser-tab[selected]"); + Assert.equal( + window.getComputedStyle(selectedTab).color, + rgbToCSS(TOOLBAR_TEXT_COLOR), + "Selected tab text color should be set." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js new file mode 100644 index 0000000000..64155006d9 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_themes_warnings.js @@ -0,0 +1,143 @@ +"use strict"; + +const { AddonSettings } = ChromeUtils.import( + "resource://gre/modules/addons/AddonSettings.jsm" +); + +// This test checks that theme warnings are properly emitted. + +function waitForConsole(task, message) { + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: new RegExp(message), + }, + ]); + await task(); + SimpleTest.endMonitorConsole(); + }); +} + +add_task(async function setup() { + SimpleTest.waitForExplicitFinish(); +}); + +add_task(async function test_static_theme() { + for (const property of ["colors", "images", "properties"]) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + [property]: { + such_property: "much_wow", + }, + }, + }, + }); + await waitForConsole( + extension.startup, + `Unrecognized theme property found: ${property}.such_property` + ); + await extension.unload(); + } +}); + +add_task(async function test_dynamic_theme() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener((msg, details) => { + if (msg === "update-theme") { + browser.theme.update(details).then(() => { + browser.test.sendMessage("theme-updated"); + }); + } else { + browser.theme.reset().then(() => { + browser.test.sendMessage("theme-reset"); + }); + } + }); + }, + }); + + await extension.startup(); + + for (const property of ["colors", "images", "properties"]) { + extension.sendMessage("update-theme", { + [property]: { + such_property: "much_wow", + }, + }); + await waitForConsole( + () => extension.awaitMessage("theme-updated"), + `Unrecognized theme property found: ${property}.such_property` + ); + } + + await extension.unload(); +}); + +add_task(async function test_experiments_enabled() { + info("Testing that experiments are handled correctly on nightly and deved"); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + properties: { + such_property: "much_wow", + unknown_property: "very_unknown", + }, + }, + theme_experiment: { + properties: { + such_property: "--such-property", + }, + }, + }, + }); + if (!AddonSettings.EXPERIMENTS_ENABLED) { + await waitForConsole( + extension.startup, + "This extension is not allowed to run theme experiments" + ); + } else { + await waitForConsole( + extension.startup, + "Unrecognized theme property found: properties.unknown_property" + ); + } + await extension.unload(); +}); + +add_task(async function test_experiments_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.experiments.enabled", false]], + }); + + info( + "Testing that experiments are handled correctly when experiements pref is disabled" + ); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + properties: { + such_property: "much_wow", + }, + }, + theme_experiment: { + properties: { + such_property: "--such-property", + }, + }, + }, + }); + await waitForConsole( + extension.startup, + "This extension is not allowed to run theme experiments" + ); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js b/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js new file mode 100644 index 0000000000..96a2216067 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_thumbnails_bg_extension.js @@ -0,0 +1,94 @@ +"use strict"; + +/* import-globals-from ../../../thumbnails/test/head.js */ +loadTestSubscript("../../../thumbnails/test/head.js"); + +// The service that creates thumbnails of webpages in the background loads a +// web page in the background (with several features disabled). Extensions +// should be able to observe requests, but not run content scripts. +add_task(async function test_thumbnails_background_visibility_to_extensions() { + const iframeUrl = "http://example.com/?iframe"; + const testPageUrl = bgTestPageURL({ iframe: iframeUrl }); + // ^ testPageUrl is http://mochi.test:8888/.../thumbnails_background.sjs?... + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + // ":8888" omitted due to bug 1362809. + matches: [ + "http://mochi.test/*/thumbnails_background.sjs*", + "http://example.com/?iframe*", + ], + js: ["contentscript.js"], + run_at: "document_start", + all_frames: true, + }, + ], + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/*", + "http://mochi.test/*", + ], + }, + files: { + "contentscript.js": () => { + // Content scripts are not expected to be run in the page of the + // thumbnail service, so this should never execute. + new Image().src = "http://example.com/?unexpected-content-script"; + browser.test.fail("Content script ran in thumbs, unexpectedly."); + }, + }, + background() { + let requests = []; + browser.webRequest.onBeforeRequest.addListener( + ({ url, tabId, frameId, type }) => { + browser.test.assertEq(-1, tabId, "Thumb page is not a tab"); + // We want to know if frameId is 0 or non-negative (or possibly -1). + if (type === "sub_frame") { + browser.test.assertTrue(frameId > 0, `frame ${frameId} for ${url}`); + } else { + browser.test.assertEq(0, frameId, `frameId for ${type} ${url}`); + } + requests.push({ type, url }); + }, + { + types: ["main_frame", "sub_frame", "image"], + urls: ["*://*/*"], + }, + ["blocking"] + ); + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("get-results", msg, "expected message"); + browser.test.sendMessage("webRequest-results", requests); + }); + }, + }); + + await extension.startup(); + + ok(!thumbnailExists(testPageUrl), "Thumbnail should not be cached yet."); + + await bgCapture(testPageUrl); + ok(thumbnailExists(testPageUrl), "Thumbnail should be cached after capture"); + removeThumbnail(testPageUrl); + + extension.sendMessage("get-results"); + Assert.deepEqual( + await extension.awaitMessage("webRequest-results"), + [ + { + type: "main_frame", + url: testPageUrl, + }, + { + type: "sub_frame", + url: iframeUrl, + }, + ], + "Expected requests via webRequest" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js new file mode 100644 index 0000000000..674a10a5ef --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_webRequest_redirect_mozextension.js @@ -0,0 +1,48 @@ +"use strict"; + +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1573456 +add_task(async function test_mozextension_page_loaded_in_extension_process() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "https://example.com/*", + ], + web_accessible_resources: ["test.html"], + }, + files: { + "test.html": '<!DOCTYPE html><script src="test.js"></script>', + "test.js": () => { + browser.test.assertTrue( + browser.webRequest, + "webRequest API should be available" + ); + + browser.test.sendMessage("test_done"); + }, + }, + background: () => { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { + redirectUrl: browser.runtime.getURL("test.html"), + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/redir" + ); + + await extension.awaitMessage("test_done"); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js new file mode 100644 index 0000000000..21ef6bf460 --- /dev/null +++ b/toolkit/components/extensions/test/browser/browser_ext_windows_popup_title.js @@ -0,0 +1,61 @@ +"use strict"; + +// Check that extension popup windows contain the name of the extension +// as well as the title of the loaded document, but not the URL. +add_task(async function test_popup_title() { + const name = "custom_title_number_9_please"; + const docTitle = "popup-test-title"; + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + name, + permissions: ["tabs"], + }, + + async background() { + let popup; + + // Called after the popup loads + browser.runtime.onMessage.addListener(async ({ docTitle }) => { + const { id } = await popup; + const { title } = await browser.windows.get(id); + browser.windows.remove(id); + + browser.test.assertTrue( + title.includes(name), + "popup title must include extension name" + ); + browser.test.assertTrue( + title.includes(docTitle), + "popup title must include extension document title" + ); + browser.test.assertFalse( + title.includes("moz-extension:"), + "popup title must not include extension URL" + ); + + browser.test.notifyPass("popup-window-title"); + }); + + popup = browser.windows.create({ + url: "/index.html", + type: "popup", + }); + }, + files: { + "index.html": `<!doctype html> + <meta charset="utf-8"> + <title>${docTitle}</title>, + <script src="index.js"></script> + `, + "index.js": `addEventListener( + "load", + () => browser.runtime.sendMessage({docTitle: document.title}) + );`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("popup-window-title"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/browser/data/test-download.txt b/toolkit/components/extensions/test/browser/data/test-download.txt new file mode 100644 index 0000000000..f416e0e291 --- /dev/null +++ b/toolkit/components/extensions/test/browser/data/test-download.txt @@ -0,0 +1 @@ +test download content diff --git a/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html b/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html new file mode 100644 index 0000000000..85410abfcd --- /dev/null +++ b/toolkit/components/extensions/test/browser/data/test_downloads_referrer.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Test downloads referrer</title> + </head> + <body> + <a href="test-download.txt" class="test-link">test link</a> + </body> +</html> diff --git a/toolkit/components/extensions/test/browser/head.js b/toolkit/components/extensions/test/browser/head.js new file mode 100644 index 0000000000..0dd1a1666c --- /dev/null +++ b/toolkit/components/extensions/test/browser/head.js @@ -0,0 +1,103 @@ +/* exported ACCENT_COLOR, BACKGROUND, ENCODED_IMAGE_DATA, FRAME_COLOR, TAB_TEXT_COLOR, + TEXT_COLOR, TAB_BACKGROUND_TEXT_COLOR, imageBufferFromDataURI, hexToCSS, hexToRGB, testBorderColor, + waitForTransition, loadTestSubscript */ + +"use strict"; + +const BACKGROUND = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0" + + "DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; +const ENCODED_IMAGE_DATA = + "iVBORw0KGgoAAAANSUhEUgAAABkAAAAZCAYAAADE6YVjAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0h" + + "STQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAAdhwAAHYcBj+XxZQAAB5dJREFUSMd" + + "91vmTlEcZB/Bvd7/vO+/ce83O3gfLDUsC4VgIghBUEo2GM9GCFTaQBEISA1qIEVNQ4aggJDGIgAGTlFUKKcqKQpVHaQyny7FrCMiywp4ze+/Mzs67M/P" + + "O+3a3v5jdWo32H/B86vv0U083weecV3+0C8lkEh6PhzS3tuLkieMSAKo3fW9Mb1eoUtM0jemerukLllzrbGlKheovUpeqkmt113hPfx/27tyFF7+/bbg" + + "e+U9g20s7kEwmMXXGNLrp2fWi4V5z/tFjJ3fWX726INbfU2xx0yelkJAKdJf3Xl5+2QcPTpv2U0JZR+u92+xvly5ygKDm20/hlX17/jvB6VNnIKXEOyd" + + "O0iFh4PLVy0XV1U83Vk54QI7JK+bl+UE5vjRfTCzJ5eWBTFEayBLjisvljKmzwmtWrVkEAPNmVrEZkyfh+fU1n59k//7X4Fbz8MK2DRSAWLNq/Yc36y9" + + "+3UVMsyAYVPMy/MTvdBKvriJhphDq6xa9vf0i1GMwPVhM5s9bsLw/EvtN2kywwnw/nzBuLDZs2z4auXGjHuvWbmBQdT5v7qytn165fLCyyGtXTR6j5GV" + + "kIsvlBCwTVNgQhMKCRDQ2iIbmJv7BpU+Ykl02UFOzdt6gkbzTEQ5Rl2KL3W8eGUE+/ssFXK+rJQ8vWigLgjk5z9ZsvpOniJzVi+ZKTUhCuATTKCjhoLA" + + "hhQAsjrSZBJcm7rZ22O+ev6mMmTLj55eu1T+jU8GOH/kJf2TZCiifIQsXfwEbN2yktxoaeYbf93DKSORMnTOZE0aZaVlQGYVKJCgjEJSCcgLB0xDERjI" + + "NFBUEaXmuB20t95eEutr0xrufpo4eepMAkMPIxx+dx9at25EWQNXsh77q0Bzwen0ShEF32HCrCpjksAWHFAKqokFhgEJt2DKJeFoQv8eDuz3duaseXZY" + + "dixthaQ+NRlRCcKO+FgCweP68wswMF/yZWcTkNpLJFAZEGi6XC07NCUIIoqaNSLQfFALCEpCSEL/bK/wuw+12sKlDQzKs6k5yZt+rI+2aNKUSNdUbSSQ" + + "Wh2mJP46rGPeYrjtkY0M7jFgciUQCiqqgrCAfBTle3G9rR1NHN3SnDq9Lg49QlBQEcbfbQCKZlhQEDkXBih27RpDOrmacfP8YB4CfHT7uNXrCMFM2FdD" + + "BVQ5TE/A5HbDSJoSpQXAbXm8A4b5+gKrwulU4KKEBnwuzHpiQu+n1jQoQsM+9cYQMT9fvf/FLBYTaDqdzbfgft95PKzbPyQqwnlAXGkJtGIgNYnJpMfw" + + "OghLG0GJE0ZdiaOnsQ16OD6XZLkiRROdAgud5sxk8ridsy/pQU1VlOIkZN6QtAGnx0FA0AtXvIA4C5OX4kOWbiLRhQBDApTmgJuLwEonMgBvjgpmgjIE" + + "hhX7DAIVKNeqE05/dJbgEgRy5eOJ1ieXr1gJA7ZNLTrVVlAZLyopLJAUlHsrAMrwwrRQ4t6E5VHgSBExjcGpO0JQNizCE05a41dhOi+cXXVm144e1AHD" + + "1vXfFMOLy+KSHEDoEJLZ8s+ZWKpUusWwpFKiMUQ4jbiAaj8Hp9oExBsMCUpEIfD6JLKZjKJVGV3RIZGdm0qxA5qmz+/cgMhBVuuMRewRRGF7fe4BYHMg" + + "N5LxdV3vhy1EjrrjA5GAyTuKpFHricfS0dSDNCQRPoSyQgSSPI+UBEtwShiWUQEHw5mMvbz4JRcXvDr3B3dBG1sq5X53GlMcX4JWVTyvRQcOumDD2vfK" + + "cjOqiQDZPGBF2ryUEnjRhJlP4d6/BiQ1TABPKiyQhgtzvjPCJlQ/OGRwauqESSUPX68U3Vi4fGeH83Hwc3bYHBWUV0m0k4HB6z7aGu6sznDos00R3exg" + + "l5ZMwc+FMaJoKKxHFnbo6DMYiELBlqLOXDBq8dsvuPTfKALpwdbX42iMLsHjLd0Zv4RNvvY1wZxdZunyVDGZm6D/47sv12RqbmOPVhG5LGnAH4S8sgu7" + + "1oK/pn2BWAoYw0dDbaTd19iqlZROejwzEjqgMSuXUifak8jF49JnNI0kAoGrBfET7+uXOrS+y5ta21JzZsw7faW45XJaXxSvyAtTpkOi483fwtAWP1wt" + + "vrhvd/VFx+26zojr9Les2PnfaTNu4cuGvvKe9BVv3/RgARiNTpk/Hod17MWikxcqzzfhK/+1jL2xc+YQAX1ISDHLV7WTpQQaLcASzPEiB41ZrmEeHkrT" + + "Q49uz/aXn+iilLKXq/MmlS0e/jFcuX4SmaQAAKSXlnIvVy1aQ6EBMFgRyCznDpfGFwdKqirF2tu5SdIeGrkiP+KS5yb7dHtIKsnI++kP9rS8RQvjmxxe" + + "jePxD2HHwwP9FdCllurGhUbx14CAbiMc4Y2qVJqwLbo0qfpdLSilILB4Xg0mT6h7vnSWzZn9RoaynobWF3K6rk1NmzMWZ83/+37+V4a1cVg5JACYF45b" + + "FGVVWOFS2V1HUCjOdBqW0Q9fYb7N9/tcSptnldjpott8rFEXBO+f+NKrWMHL9Wu1nSUAIAaUUa59aAyE43E4X3bD8W6K5K6x1h1snRaMDJDuQf7+vrzf" + + "eG+mgfrcLHh3C79bx6wttGEqERiH/AjPohWMouv2ZAAAAAElFTkSuQmCC"; +const ACCENT_COLOR = "#a14040"; +const TEXT_COLOR = "#fac96e"; +// For testing aliases of the colors above: +const FRAME_COLOR = [71, 105, 91]; +const TAB_BACKGROUND_TEXT_COLOR = [207, 221, 192, 0.9]; + +function hexToRGB(hex) { + if (!hex) { + return null; + } + hex = parseInt(hex.indexOf("#") > -1 ? hex.substring(1) : hex, 16); + return [hex >> 16, (hex & 0x00ff00) >> 8, hex & 0x0000ff]; +} + +function rgbToCSS(rgb) { + return `rgb(${rgb.join(", ")})`; +} + +function hexToCSS(hex) { + if (!hex) { + return null; + } + return rgbToCSS(hexToRGB(hex)); +} + +function imageBufferFromDataURI(encodedImageData) { + let decodedImageData = atob(encodedImageData); + return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer; +} + +function waitForTransition(element, propertyName) { + return BrowserTestUtils.waitForEvent( + element, + "transitionend", + false, + event => { + return event.target == element && event.propertyName == propertyName; + } + ); +} + +function testBorderColor(element, expected) { + let computedStyle = window.getComputedStyle(element); + Assert.equal( + computedStyle.borderLeftColor, + hexToCSS(expected), + "Element left border color should be set." + ); + Assert.equal( + computedStyle.borderRightColor, + hexToCSS(expected), + "Element right border color should be set." + ); + Assert.equal( + computedStyle.borderTopColor, + hexToCSS(expected), + "Element top border color should be set." + ); + Assert.equal( + computedStyle.borderBottomColor, + hexToCSS(expected), + "Element bottom border color should be set." + ); +} + +function loadTestSubscript(filePath) { + Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this); +} diff --git a/toolkit/components/extensions/test/browser/head_serviceworker.js b/toolkit/components/extensions/test/browser/head_serviceworker.js new file mode 100644 index 0000000000..012dcfe284 --- /dev/null +++ b/toolkit/components/extensions/test/browser/head_serviceworker.js @@ -0,0 +1,123 @@ +"use strict"; + +/* exported assert_background_serviceworker_pref_enabled, + * getBackgroundServiceWorkerRegistration, + * getServiceWorkerInfo, getServiceWorkerState, + * waitForServiceWorkerRegistrationsRemoved, waitForServiceWorkerTerminated + */ + +async function assert_background_serviceworker_pref_enabled() { + is( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + true, + "Expect extensions.backgroundServiceWorker.enabled to be true" + ); +} + +// Return the name of the enum corresponding to the worker's state (ex: "STATE_ACTIVATED") +// because nsIServiceWorkerInfo doesn't currently provide a comparable string-returning getter. +function getServiceWorkerState(workerInfo) { + const map = Object.keys(workerInfo) + .filter(k => k.startsWith("STATE_")) + .reduce((map, name) => { + map.set(workerInfo[name], name); + return map; + }, new Map()); + return map.has(workerInfo.state) + ? map.get(workerInfo.state) + : "state: ${workerInfo.state}"; +} + +function getServiceWorkerInfo(swRegInfo) { + const { + evaluatingWorker, + installingWorker, + waitingWorker, + activeWorker, + } = swRegInfo; + return evaluatingWorker || installingWorker || waitingWorker || activeWorker; +} + +async function waitForServiceWorkerTerminated(swRegInfo) { + info(`Wait all ${swRegInfo.scope} workers to be terminated`); + + try { + await BrowserTestUtils.waitForCondition( + () => !getServiceWorkerInfo(swRegInfo) + ); + } catch (err) { + const workerInfo = getServiceWorkerInfo(swRegInfo); + if (workerInfo) { + ok( + false, + `Error while waiting for workers for scope ${swRegInfo.scope} to be terminated. ` + + `Found a worker in state: ${getServiceWorkerState(workerInfo)}` + ); + return; + } + + throw err; + } +} + +function getBackgroundServiceWorkerRegistration(extension) { + const policy = WebExtensionPolicy.getByHostname(extension.uuid); + const expectedSWScope = policy.getURL("/"); + const expectedScriptURL = policy.extension.backgroundWorkerScript || ""; + + ok( + expectedScriptURL.startsWith(expectedSWScope), + `Extension does include a valid background.service_worker: ${expectedScriptURL}` + ); + + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + let swReg; + let regs = swm.getAllRegistrations(); + + for (let i = 0; i < regs.length; i++) { + let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (reg.scriptSpec === expectedScriptURL) { + swReg = reg; + break; + } + } + + ok(swReg, `Found service worker registration for ${expectedScriptURL}`); + + is( + swReg.scope, + expectedSWScope, + "The extension background worker registration has the expected scope URL" + ); + + return swReg; +} + +async function waitForServiceWorkerRegistrationsRemoved(extension) { + info(`Wait ${extension.id} service worker registration to be deleted`); + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + let baseURI = Services.io.newURI(`moz-extension://${extension.uuid}/`); + let principal = Services.scriptSecurityManager.createContentPrincipal( + baseURI, + {} + ); + + await BrowserTestUtils.waitForCondition(() => { + let regs = swm.getAllRegistrations(); + + for (let i = 0; i < regs.length; i++) { + let reg = regs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (principal.equals(reg.principal)) { + return false; + } + } + + info(`All ${extension.id} service worker registrations are gone`); + return true; + }, `All ${extension.id} service worker registrations should be deleted`); +} diff --git a/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json new file mode 100644 index 0000000000..5ed13a1b18 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/manifest.json @@ -0,0 +1,11 @@ +{ + "manifest_version": 2, + "name": "Test Extension with Background Service Worker", + "version": "1", + "applications": { + "gecko": { "id": "extension-with-bg-sw@test" } + }, + "background": { + "service_worker": "sw.js" + } +}
\ No newline at end of file diff --git a/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js new file mode 100644 index 0000000000..2282e6a64b --- /dev/null +++ b/toolkit/components/extensions/test/marionette/data/extension-with-bg-sw/sw.js @@ -0,0 +1,3 @@ +"use strict"; + +dump("extension-with-bg-sw: sw.js loaded"); diff --git a/toolkit/components/extensions/test/marionette/manifest.ini b/toolkit/components/extensions/test/marionette/manifest.ini new file mode 100644 index 0000000000..78006ccf1e --- /dev/null +++ b/toolkit/components/extensions/test/marionette/manifest.ini @@ -0,0 +1 @@ +[test_extension_serviceworkers_purged_on_pref_disabled.py]
\ No newline at end of file diff --git a/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py new file mode 100644 index 0000000000..64e376adf0 --- /dev/null +++ b/toolkit/components/extensions/test/marionette/test_extension_serviceworkers_purged_on_pref_disabled.py @@ -0,0 +1,82 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +from marionette_driver import Wait +from marionette_driver.addons import Addons +from marionette_harness import MarionetteTestCase + +import os + +EXT_ID = "extension-with-bg-sw@test" +EXT_DIR_PATH = "extension-with-bg-sw" +PREF_BG_SW_ENABLED = "extensions.backgroundServiceWorker.enabled" + + +class PurgeExtensionServiceWorkersOnPrefDisabled(MarionetteTestCase): + def setUp(self): + super(PurgeExtensionServiceWorkersOnPrefDisabled, self).setUp() + self.test_extension_id = EXT_ID + # Flip the "mirror: once" pref and restart Firefox to be able + # to run the extension successfully. + self.marionette.set_pref(PREF_BG_SW_ENABLED, True) + self.marionette.restart(in_app=True) + + def tearDown(self): + self.marionette.restart(clean=True) + super(PurgeExtensionServiceWorkersOnPrefDisabled, self).tearDown() + + def test_unregistering_service_worker_when_clearing_data(self): + self.install_extension_with_service_worker() + + # Flip the pref to false and restart again to verify that the + # service worker registration has been removed as expected. + self.marionette.set_pref(PREF_BG_SW_ENABLED, False) + self.marionette.restart(in_app=True) + self.assertFalse(self.is_extension_service_worker_registered) + + def install_extension_with_service_worker(self): + addons = Addons(self.marionette) + test_extension_path = os.path.join( + os.path.dirname(self.filepath), "data", EXT_DIR_PATH + ) + addons.install(test_extension_path, temp=True) + self.test_extension_base_url = self.get_extension_url() + Wait(self.marionette).until( + lambda _: self.is_extension_service_worker_registered, + message="Wait the extension service worker to be registered", + ) + + def get_extension_url(self, path="/"): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + let policy = WebExtensionPolicy.getByID(arguments[0]); + return policy.getURL(arguments[1]) + """, + script_args=(self.test_extension_id, path), + ) + + @property + def is_extension_service_worker_registered(self): + with self.marionette.using_context("chrome"): + return self.marionette.execute_script( + """ + let serviceWorkerManager = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + let serviceWorkers = serviceWorkerManager.getAllRegistrations(); + for (let i = 0; i < serviceWorkers.length; i++) { + let sw = serviceWorkers.queryElementAt( + i, + Ci.nsIServiceWorkerRegistrationInfo + ); + if (sw.scope == arguments[0]) { + return true; + } + } + return false; + """, + script_args=(self.test_extension_base_url,), + ) diff --git a/toolkit/components/extensions/test/mochitest/.eslintrc.js b/toolkit/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..a776405c9d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,12 @@ +"use strict"; + +module.exports = { + env: { + browser: true, + webextensions: true, + }, + + rules: { + "no-shadow": 0, + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/chrome.ini b/toolkit/components/extensions/test/mochitest/chrome.ini new file mode 100644 index 0000000000..209f11b864 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome.ini @@ -0,0 +1,37 @@ +[DEFAULT] +support-files = + chrome_cleanup_script.js + head.js + head_cookies.js + file_image_good.png + file_image_great.png + file_sample.html + file_with_images.html + webrequest_chromeworker.js + webrequest_test.jsm +prefs = + security.mixed_content.upgrade_display_content=false +tags = webextensions in-process-webextensions + +# NO NEW TESTS. mochitest-chrome does not run under e10s, avoid adding new +# tests here unless absolutely necessary. + +[test_chrome_ext_contentscript_data_uri.html] +[test_chrome_ext_contentscript_telemetry.html] +skip-if = (os == 'linux' && bits == 64) #Bug 1393920 +[test_chrome_ext_contentscript_unrecognizedprop_warning.html] +[test_chrome_ext_downloads_open.html] +[test_chrome_ext_downloads_saveAs.html] +skip-if = (verify && !debug && (os == 'win')) || (os == 'android') +[test_chrome_ext_downloads_uniquify.html] +[test_chrome_ext_permissions.html] +skip-if = os == 'android' # Bug 1350559 +[test_chrome_ext_trackingprotection.html] +[test_chrome_ext_webnavigation_resolved_urls.html] +[test_chrome_ext_webrequest_background_events.html] +[test_chrome_ext_webrequest_host_permissions.html] +skip-if = verify +[test_chrome_ext_webrequest_mozextension.html] +skip-if = true # Bug 1404172 +[test_chrome_native_messaging_paths.html] +skip-if = os != "mac" && os != "linux" diff --git a/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js new file mode 100644 index 0000000000..397996b15c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome_cleanup_script.js @@ -0,0 +1,66 @@ +"use strict"; + +/* global addMessageListener, sendAsyncMessage */ + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +let listener = msg => { + void (msg instanceof Ci.nsIConsoleMessage); + dump(`Console message: ${msg}\n`); +}; + +Services.console.registerListener(listener); + +let getBrowserApp, getTabBrowser; +if (AppConstants.MOZ_BUILD_APP === "mobile/android") { + getBrowserApp = win => win.BrowserApp; + getTabBrowser = tab => tab.browser; +} else { + getBrowserApp = win => win.gBrowser; + getTabBrowser = tab => tab.linkedBrowser; +} + +function* iterBrowserWindows() { + for (let win of Services.wm.getEnumerator("navigator:browser")) { + if (!win.closed && getBrowserApp(win)) { + yield win; + } + } +} + +let initialTabs = new Map(); +for (let win of iterBrowserWindows()) { + initialTabs.set(win, new Set(getBrowserApp(win).tabs)); +} + +addMessageListener("check-cleanup", extensionId => { + Services.console.unregisterListener(listener); + + let results = { + extraWindows: [], + extraTabs: [], + }; + + for (let win of iterBrowserWindows()) { + if (initialTabs.has(win)) { + let tabs = initialTabs.get(win); + + for (let tab of getBrowserApp(win).tabs) { + if (!tabs.has(tab)) { + results.extraTabs.push(getTabBrowser(tab).currentURI.spec); + } + } + } else { + results.extraWindows.push( + Array.from(win.gBrowser.tabs, tab => getTabBrowser(tab).currentURI.spec) + ); + } + } + + initialTabs = null; + + sendAsyncMessage("cleanup-results", results); +}); diff --git a/toolkit/components/extensions/test/mochitest/chrome_head.js b/toolkit/components/extensions/test/mochitest/chrome_head.js new file mode 100644 index 0000000000..3918c74e44 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/chrome_head.js @@ -0,0 +1 @@ +"use strict"; diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html new file mode 100644 index 0000000000..663ebc6112 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page1.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="file_WebNavigation_page2.html" width="200" height="200"></iframe> + +<form> +</form> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html new file mode 100644 index 0000000000..cc1acc83d6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page2.html @@ -0,0 +1,7 @@ +<!DOCTYPE HTML> + +<html> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html new file mode 100644 index 0000000000..a0a26a2e9d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebNavigation_page3.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<a id="elt" href="file_WebNavigation_page3.html#ref">click me</a> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html new file mode 100644 index 0000000000..24c7a42986 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_WebRequest_page3.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<script> +"use strict"; +</script> +</head> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contains_iframe.html b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html new file mode 100644 index 0000000000..2b9344f463 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contains_iframe.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<iframe src="http://example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_img.html"> +</iframe> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contains_img.html b/toolkit/components/extensions/test/mochitest/file_contains_img.html new file mode 100644 index 0000000000..c1112acbd8 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contains_img.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<img src="file_image_good.png"/> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html new file mode 100644 index 0000000000..6c1675cb47 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML> + +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <iframe id="emptyframe"></iframe> + <iframe id="regularframe" src="http://test1.example.com/"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html new file mode 100644 index 0000000000..3b102b3d67 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <iframe srcdoc="<iframe src='http://test1.example.com/'></iframe>"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html new file mode 100644 index 0000000000..dda5169d69 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <iframe id="frame" src="http://test2.example.com/"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_green.html b/toolkit/components/extensions/test/mochitest/file_green.html new file mode 100644 index 0000000000..20755c5b56 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_green.html @@ -0,0 +1,3 @@ +<meta charset=utf-8> +<title>Super green test page</title> +<body style="background: #0f0"> diff --git a/toolkit/components/extensions/test/mochitest/file_image_bad.png b/toolkit/components/extensions/test/mochitest/file_image_bad.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_image_bad.png diff --git a/toolkit/components/extensions/test/mochitest/file_image_good.png b/toolkit/components/extensions/test/mochitest/file_image_good.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_image_good.png diff --git a/toolkit/components/extensions/test/mochitest/file_image_great.png b/toolkit/components/extensions/test/mochitest/file_image_great.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_image_great.png diff --git a/toolkit/components/extensions/test/mochitest/file_image_redirect.png b/toolkit/components/extensions/test/mochitest/file_image_redirect.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_image_redirect.png diff --git a/toolkit/components/extensions/test/mochitest/file_indexedDB.html b/toolkit/components/extensions/test/mochitest/file_indexedDB.html new file mode 100644 index 0000000000..65b7e0ad2f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_indexedDB.html @@ -0,0 +1,28 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <script> +"use strict"; + +const objectStoreName = "Objects"; + +let test = {key: 0, value: "test"}; + +let request = indexedDB.open("WebExtensionTest", 1); +request.onupgradeneeded = event => { + let db = event.target.result; + let objectStore = db.createObjectStore(objectStoreName, + {autoIncrement: 0}); + request = objectStore.add(test.value, test.key); + request.onsuccess = event => { + db.close(); + window.postMessage("indexedDBCreated", "*"); + }; +}; + </script> + </head> + <body> + This is a test page. + </body> +<html> diff --git a/toolkit/components/extensions/test/mochitest/file_mixed.html b/toolkit/components/extensions/test/mochitest/file_mixed.html new file mode 100644 index 0000000000..f3c7dda580 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_mixed.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> +<img id="bad-image" src="http://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png" /> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html b/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html new file mode 100644 index 0000000000..b8fda2369a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html @@ -0,0 +1,30 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>1450965 Skip Cors Check for Early WebExtention Redirects</title> +</head> +<body> + <pre id="c"> + Fetching ... + </pre> + <script> + "use strict"; + let c = document.querySelector("#c"); + const channel = new BroadcastChannel("test_bus"); + function l(t) { c.innerText += `${t}\n`; } + + fetch("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_cors_blocked.txt") + .then(r => r.text()) + .then(t => { + // This Request should have been redirected to /file_sample.txt in + // onBeforeRequest. So the text should be 'Sample' + l(`Loaded: ${t}`); + channel.postMessage(t); + }).catch(e => { + // The Redirect Failed, most likly due to a CORS Error + l(`e`); + channel.postMessage(e.toString()); + }); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html b/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html new file mode 100644 index 0000000000..fe8e5bea44 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Bug 1434357: Allow Web Request API to redirect to data: URI</title> +</head> +<body> + <div id="testdiv">foo</div> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_remote_frame.html b/toolkit/components/extensions/test/mochitest/file_remote_frame.html new file mode 100644 index 0000000000..f1b9240092 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_remote_frame.html @@ -0,0 +1,20 @@ +<!DOCTYPE> +<html> + <head> + <meta charset="utf-8"> + <script> + "use strict"; + var response = { + tabs: false, + cookie: document.cookie, + }; + try { + browser.tabs.create({url: "file_sample.html"}); + response.tabs = true; + } catch (e) { + // ok + } + window.parent.postMessage(response, "*"); + </script> + </head> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_sample.html b/toolkit/components/extensions/test/mochitest/file_sample.html new file mode 100644 index 0000000000..a20e49a1f0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_sample.txt b/toolkit/components/extensions/test/mochitest/file_sample.txt new file mode 100644 index 0000000000..c02cd532b1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.txt @@ -0,0 +1 @@ +Sample
\ No newline at end of file diff --git a/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ b/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ new file mode 100644 index 0000000000..cb762eff80 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_sample.txt^headers^ @@ -0,0 +1 @@ +Access-Control-Allow-Origin: * diff --git a/toolkit/components/extensions/test/mochitest/file_script_bad.js b/toolkit/components/extensions/test/mochitest/file_script_bad.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_bad.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/mochitest/file_script_good.js b/toolkit/components/extensions/test/mochitest/file_script_good.js new file mode 100644 index 0000000000..14e959aa5c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_good.js @@ -0,0 +1,12 @@ +"use strict"; + +window.success = window.success ? window.success + 1 : 1; + +{ + let scripts = document.getElementsByTagName("script"); + let url = new URL(scripts[scripts.length - 1].src); + let flag = url.searchParams.get("q"); + if (flag) { + window.postMessage(flag, "*"); + } +} diff --git a/toolkit/components/extensions/test/mochitest/file_script_redirect.js b/toolkit/components/extensions/test/mochitest/file_script_redirect.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_redirect.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/mochitest/file_script_xhr.js b/toolkit/components/extensions/test/mochitest/file_script_xhr.js new file mode 100644 index 0000000000..ad01f74253 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_script_xhr.js @@ -0,0 +1,9 @@ +"use strict"; + +var request = new XMLHttpRequest(); +request.open( + "get", + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/xhr_resource", + false +); +request.send(); diff --git a/toolkit/components/extensions/test/mochitest/file_serviceWorker.html b/toolkit/components/extensions/test/mochitest/file_serviceWorker.html new file mode 100644 index 0000000000..d2b99769cc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_serviceWorker.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8"> + <script> + "use strict"; + + navigator.serviceWorker.register("serviceWorker.js").then(() => { + window.postMessage("serviceWorkerRegistered", "*"); + }); + </script> + </head> + <body> + This is a test page. + </body> +<html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html new file mode 100644 index 0000000000..909a1f9e36 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_frame.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +let req = new XMLHttpRequest(); +req.open("GET", "/xhr_sandboxed"); +req.send(); + +let sandbox = document.createElement("iframe"); +sandbox.setAttribute("sandbox", "allow-scripts"); +sandbox.setAttribute("src", "file_simple_sandboxed_subframe.html"); +document.documentElement.appendChild(sandbox); +</script> +<img src="file_image_great.png"/> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html new file mode 100644 index 0000000000..a0a437d0eb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_sandboxed_subframe.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html new file mode 100644 index 0000000000..f6ef67277d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +let req = new XMLHttpRequest(); +req.open("GET", "http://example.org/example.txt"); +req.send(); +</script> +<img src="file_image_good.png"/> +<iframe src="file_simple_xhr_frame.html"/> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html new file mode 100644 index 0000000000..7f38247ac0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +let req = new XMLHttpRequest(); +req.open("GET", "/xhr_resource"); +req.send(); +</script> +<img src="file_image_bad.png"/> +<iframe src="file_simple_xhr_frame2.html"/> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html new file mode 100644 index 0000000000..6174a0b402 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_simple_xhr_frame2.html @@ -0,0 +1,23 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +let req = new XMLHttpRequest(); +req.open("GET", "/xhr_resource_2"); +req.send(); + +let sandbox = document.createElement("iframe"); +sandbox.setAttribute("sandbox", "allow-scripts"); +sandbox.setAttribute("src", "file_simple_sandboxed_frame.html"); +document.documentElement.appendChild(sandbox); +</script> +<img src="file_image_redirect.png"/> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_streamfilter.txt b/toolkit/components/extensions/test/mochitest/file_streamfilter.txt new file mode 100644 index 0000000000..56cdd85e1d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_streamfilter.txt @@ -0,0 +1 @@ +Middle diff --git a/toolkit/components/extensions/test/mochitest/file_style_bad.css b/toolkit/components/extensions/test/mochitest/file_style_bad.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_bad.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/mochitest/file_style_good.css b/toolkit/components/extensions/test/mochitest/file_style_good.css new file mode 100644 index 0000000000..46f9774b5f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_good.css @@ -0,0 +1,3 @@ +#test { + color: red; +} diff --git a/toolkit/components/extensions/test/mochitest/file_style_redirect.css b/toolkit/components/extensions/test/mochitest/file_style_redirect.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_style_redirect.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html new file mode 100644 index 0000000000..63f503ad3c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html @@ -0,0 +1,10 @@ +<!DOCTYPE HTML> + +<html> +<head> + <title>The Title</title> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html new file mode 100644 index 0000000000..87ac7a2f64 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html @@ -0,0 +1,11 @@ +<!DOCTYPE HTML> + +<html> +<head> + <title>Another Title</title> + <link href="file_image_great.png" rel="icon" type="image/png" /> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_third_party.html b/toolkit/components/extensions/test/mochitest/file_third_party.html new file mode 100644 index 0000000000..fc5a326297 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_third_party.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> + +"use strict" + +let url = new URL(location); +let img = new Image(); +img.src = `http://${url.searchParams.get("domain")}/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png`; +document.body.appendChild(img); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html b/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html new file mode 100644 index 0000000000..6ebd54d9a3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html @@ -0,0 +1,9 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body style="background: #ff9"> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html new file mode 100644 index 0000000000..cba3043f71 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> + +<html> + <head> + <meta http-equiv="refresh" content="1;dummy_page.html"> + </head> + <body> + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html new file mode 100644 index 0000000000..c5b436979f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html @@ -0,0 +1,8 @@ +<!DOCTYPE HTML> + +<html> + <head> + </head> + <body> + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ new file mode 100644 index 0000000000..574a392a15 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_clientRedirect_httpHeaders.html^headers^ @@ -0,0 +1 @@ +Refresh: 1;url=dummy_page.html diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html new file mode 100644 index 0000000000..d360bcbb13 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameClientRedirect.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="file_webNavigation_clientRedirect.html" width="200" height="200"></iframe> + +<form> +</form> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html new file mode 100644 index 0000000000..06dbd43741 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_frameRedirect.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="redirection.sjs" width="200" height="200"></iframe> + +<form> +</form> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html new file mode 100644 index 0000000000..307990714b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<body> + +<iframe src="file_webNavigation_manualSubframe_page1.html" width="200" height="200"></iframe> + +<form> +</form> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html new file mode 100644 index 0000000000..55bb7aa6ae --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page1.html @@ -0,0 +1,8 @@ +<!DOCTYPE html> + +<html> + <body> + <h1>page1</h1> + <a href="file_webNavigation_manualSubframe_page2.html">page2</a> + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html new file mode 100644 index 0000000000..8f589f8bbd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_webNavigation_manualSubframe_page2.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> + +<html> + <body> + <h1>page2</h1> + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_with_about_blank.html b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html new file mode 100644 index 0000000000..af51c2e52a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_about_blank.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <iframe id="a_b" src="about:blank"></iframe> + <iframe srcdoc="galactica actual" src="adama"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_with_images.html b/toolkit/components/extensions/test/mochitest/file_with_images.html new file mode 100644 index 0000000000..6a3c090be2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_images.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> +<head> + <meta charset="utf-8"> +</head> +<body> + <img src="https://example.com/chrome/toolkit/components/extensions/test/mochitest/file_image_good.png"> + <img src="http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_image_great.png"> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html new file mode 100644 index 0000000000..d0d2f02e2d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/file_with_xorigin_frame.html @@ -0,0 +1,6 @@ +<!DOCTYPE HTML> +<meta charset="utf-8"> + +<img src="file_image_great.png"/> +Load a cross-origin iframe from example.net <p> +<iframe src="http://example.net/tests/toolkit/components/extensions/test/mochitest/file_sample.html"></iframe> diff --git a/toolkit/components/extensions/test/mochitest/head.js b/toolkit/components/extensions/test/mochitest/head.js new file mode 100644 index 0000000000..2d26de34c7 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head.js @@ -0,0 +1,123 @@ +"use strict"; + +/* exported AppConstants, Assert */ + +var { AppConstants } = SpecialPowers.Cu.import( + "resource://gre/modules/AppConstants.jsm", + {} +); + +let remote = SpecialPowers.getBoolPref("extensions.webextensions.remote"); +if (remote) { + // We don't want to reset this at the end of the test, so that we don't have + // to spawn a new extension child process for each test unit. + SpecialPowers.setIntPref("dom.ipc.keepProcessesAlive.extension", 1); +} + +{ + let chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("chrome_cleanup_script.js") + ); + + SimpleTest.registerCleanupFunction(async () => { + await new Promise(resolve => setTimeout(resolve, 0)); + + chromeScript.sendAsyncMessage("check-cleanup"); + + let results = await chromeScript.promiseOneMessage("cleanup-results"); + chromeScript.destroy(); + + if (results.extraWindows.length || results.extraTabs.length) { + ok( + false, + `Test left extra windows or tabs: ${JSON.stringify(results)}\n` + ); + } + }); +} + +let Assert = { + // Cut-down version based on Assert.jsm. Only supports regexp and objects as + // the expected variables. + rejects(promise, expected, msg) { + return promise.then( + () => { + ok(false, msg); + }, + actual => { + let matched = false; + if (Object.prototype.toString.call(expected) == "[object RegExp]") { + if (expected.test(actual)) { + matched = true; + } + } else if (actual instanceof expected) { + matched = true; + } + + if (matched) { + ok(true, msg); + } else { + ok(false, `Unexpected exception for "${msg}": ${actual}`); + } + } + ); + }, +}; + +/* exported waitForLoad */ + +function waitForLoad(win) { + return new Promise(resolve => { + win.addEventListener( + "load", + function() { + resolve(); + }, + { capture: true, once: true } + ); + }); +} + +/* exported loadChromeScript */ +function loadChromeScript(fn) { + let wrapper = ` +const {Services} = Cu.import("resource://gre/modules/Services.jsm", {}); +(${fn.toString()})();`; + + return SpecialPowers.loadChromeScript(new Function(wrapper)); +} + +/* exported consoleMonitor */ +let consoleMonitor = { + start(messages) { + this.chromeScript = SpecialPowers.loadChromeScript( + SimpleTest.getTestFileURL("mochitest_console.js") + ); + this.chromeScript.sendAsyncMessage("consoleStart", messages); + }, + + async finished() { + let done = this.chromeScript.promiseOneMessage("consoleDone").then(done => { + this.chromeScript.destroy(); + return done; + }); + this.chromeScript.sendAsyncMessage("waitForConsole"); + let test = await done; + ok(test.ok, test.message); + }, +}; +/* exported waitForState */ + +function waitForState(sw, state) { + return new Promise(resolve => { + if (sw.state === state) { + return resolve(); + } + sw.addEventListener("statechange", function onStateChange() { + if (sw.state === state) { + sw.removeEventListener("statechange", onStateChange); + resolve(); + } + }); + }); +} diff --git a/toolkit/components/extensions/test/mochitest/head_cookies.js b/toolkit/components/extensions/test/mochitest/head_cookies.js new file mode 100644 index 0000000000..610c800c94 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_cookies.js @@ -0,0 +1,287 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported testCookies */ +/* import-globals-from head.js */ + +async function testCookies(options) { + // Changing the options object is a bit of a hack, but it allows us to easily + // pass an expiration date to the background script. + options.expiry = Date.now() / 1000 + 3600; + + async function background(backgroundOptions) { + // Ask the parent scope to change some cookies we may or may not have + // permission for. + let awaitChanges = new Promise(resolve => { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("cookies-changed", msg, "browser.test.onMessage"); + resolve(); + }); + }); + + let changed = []; + browser.cookies.onChanged.addListener(event => { + changed.push(`${event.cookie.name}:${event.cause}`); + }); + browser.test.sendMessage("change-cookies"); + + // Try to access some cookies in various ways. + let { url, domain, secure } = backgroundOptions; + + let failures = 0; + let tallyFailure = error => { + failures++; + }; + + try { + await awaitChanges; + + let cookie = await browser.cookies.get({ url, name: "foo" }); + browser.test.assertEq( + backgroundOptions.shouldPass, + cookie != null, + "should pass == get cookie" + ); + + let cookies = await browser.cookies.getAll({ domain }); + if (backgroundOptions.shouldPass) { + browser.test.assertEq(2, cookies.length, "expected number of cookies"); + } else { + browser.test.assertEq(0, cookies.length, "expected number of cookies"); + } + + await Promise.all([ + browser.cookies + .set({ + url, + domain, + secure, + name: "foo", + value: "baz", + expirationDate: backgroundOptions.expiry, + }) + .catch(tallyFailure), + browser.cookies + .set({ + url, + domain, + secure, + name: "bar", + value: "quux", + expirationDate: backgroundOptions.expiry, + }) + .catch(tallyFailure), + browser.cookies.remove({ url, name: "deleted" }), + ]); + + if (backgroundOptions.shouldPass) { + // The order of eviction events isn't guaranteed, so just check that + // it's there somewhere. + let evicted = changed.indexOf("evicted:evicted"); + if (evicted < 0) { + browser.test.fail("got no eviction event"); + } else { + browser.test.succeed("got eviction event"); + changed.splice(evicted, 1); + } + + browser.test.assertEq( + "x:explicit,x:overwrite,x:explicit,x:explicit,foo:overwrite,foo:explicit,bar:explicit,deleted:explicit", + changed.join(","), + "expected changes" + ); + } else { + browser.test.assertEq("", changed.join(","), "expected no changes"); + } + + if (!(backgroundOptions.shouldPass || backgroundOptions.shouldWrite)) { + browser.test.assertEq(2, failures, "Expected failures"); + } else { + browser.test.assertEq(0, failures, "Expected no failures"); + } + + browser.test.notifyPass("cookie-permissions"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("cookie-permissions"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: options.permissions, + }, + + background: `(${background})(${JSON.stringify(options)})`, + }); + + let stepOne = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage } = this; + addMessageListener("options", options => { + let domain = options.domain.replace(/^\.?/, "."); + // This will be evicted after we add a fourth cookie. + Services.cookies.add( + domain, + "/", + "evicted", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + // This will be modified by the background script. + Services.cookies.add( + domain, + "/", + "foo", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + // This will be deleted by the background script. + Services.cookies.add( + domain, + "/", + "deleted", + "bar", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + sendAsyncMessage("done"); + }); + }); + stepOne.sendAsyncMessage("options", options); + await stepOne.promiseOneMessage("done"); + stepOne.destroy(); + + await extension.startup(); + + await extension.awaitMessage("change-cookies"); + + let stepTwo = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage } = this; + addMessageListener("options", options => { + let domain = options.domain.replace(/^\.?/, "."); + + Services.cookies.add( + domain, + "/", + "x", + "y", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + Services.cookies.add( + domain, + "/", + "x", + "z", + options.secure, + false, + false, + options.expiry, + {}, + Ci.nsICookie.SAMESITE_NONE, + options.url.startsWith("https") + ? Ci.nsICookie.SCHEME_HTTPS + : Ci.nsICookie.SCHEME_HTTP + ); + Services.cookies.remove(domain, "x", "/", {}); + sendAsyncMessage("done"); + }); + }); + stepTwo.sendAsyncMessage("options", options); + await stepTwo.promiseOneMessage("done"); + stepTwo.destroy(); + + extension.sendMessage("cookies-changed"); + + await extension.awaitFinish("cookie-permissions"); + await extension.unload(); + + let stepThree = loadChromeScript(() => { + const { addMessageListener, sendAsyncMessage, assert } = this; + let cookieSvc = Services.cookies; + + function getCookies(host) { + let cookies = []; + for (let cookie of cookieSvc.getCookiesFromHost(host, {})) { + cookies.push(cookie); + } + return cookies.sort((a, b) => a.name.localeCompare(b.name)); + } + + addMessageListener("options", options => { + let cookies = getCookies(options.domain); + + if (options.shouldPass) { + assert.equal(cookies.length, 2, "expected two cookies for host"); + + assert.equal(cookies[0].name, "bar", "correct cookie name"); + assert.equal(cookies[0].value, "quux", "correct cookie value"); + + assert.equal(cookies[1].name, "foo", "correct cookie name"); + assert.equal(cookies[1].value, "baz", "correct cookie value"); + } else if (options.shouldWrite) { + // Note: |shouldWrite| applies only when |shouldPass| is false. + // This is necessary because, unfortunately, websites (and therefore web + // extensions) are allowed to write some cookies which they're not allowed + // to read. + assert.equal(cookies.length, 3, "expected three cookies for host"); + + assert.equal(cookies[0].name, "bar", "correct cookie name"); + assert.equal(cookies[0].value, "quux", "correct cookie value"); + + assert.equal(cookies[1].name, "deleted", "correct cookie name"); + + assert.equal(cookies[2].name, "foo", "correct cookie name"); + assert.equal(cookies[2].value, "baz", "correct cookie value"); + } else { + assert.equal(cookies.length, 2, "expected two cookies for host"); + + assert.equal(cookies[0].name, "deleted", "correct second cookie name"); + + assert.equal(cookies[1].name, "foo", "correct cookie name"); + assert.equal(cookies[1].value, "bar", "correct cookie value"); + } + + for (let cookie of cookies) { + cookieSvc.remove(cookie.host, cookie.name, "/", {}); + } + // Make sure we don't silently poison subsequent tests if something goes wrong. + assert.equal(getCookies(options.domain).length, 0, "cookies cleared"); + sendAsyncMessage("done"); + }); + }); + stepThree.sendAsyncMessage("options", options); + await stepThree.promiseOneMessage("done"); + stepThree.destroy(); +} diff --git a/toolkit/components/extensions/test/mochitest/head_notifications.js b/toolkit/components/extensions/test/mochitest/head_notifications.js new file mode 100644 index 0000000000..0c8cf24350 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_notifications.js @@ -0,0 +1,169 @@ +"use strict"; + +/* exported MockAlertsService */ + +function mockServicesChromeScript() { + const MOCK_ALERTS_CID = Components.ID( + "{48068bc2-40ab-4904-8afd-4cdfb3a385f3}" + ); + const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + + const { setTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm", + {} + ); + const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + + let activeNotifications = Object.create(null); + + const mockAlertsService = { + showPersistentNotification: function(persistentData, alert, alertListener) { + this.showAlert(alert, alertListener); + }, + + showAlert: function(alert, listener) { + activeNotifications[alert.name] = { + listener: listener, + cookie: alert.cookie, + title: alert.title, + }; + + // fake async alert show event + if (listener) { + setTimeout(function() { + listener.observe(null, "alertshow", alert.cookie); + }, 100); + } + }, + + showAlertNotification: function( + imageUrl, + title, + text, + textClickable, + cookie, + alertListener, + name + ) { + this.showAlert( + { + name: name, + cookie: cookie, + title: title, + }, + alertListener + ); + }, + + closeAlert: function(name) { + let alertNotification = activeNotifications[name]; + if (alertNotification) { + if (alertNotification.listener) { + alertNotification.listener.observe( + null, + "alertfinished", + alertNotification.cookie + ); + } + delete activeNotifications[name]; + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]), + + createInstance: function(outer, iid) { + if (outer != null) { + throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION); + } + return this.QueryInterface(iid); + }, + }; + + registrar.registerFactory( + MOCK_ALERTS_CID, + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService + ); + + function clickNotifications(doClose) { + // Until we need to close a specific notification, just click them all. + for (let [name, notification] of Object.entries(activeNotifications)) { + let { listener, cookie } = notification; + listener.observe(null, "alertclickcallback", cookie); + if (doClose) { + mockAlertsService.closeAlert(name); + } + } + } + + function closeAllNotifications() { + for (let alertName of Object.keys(activeNotifications)) { + mockAlertsService.closeAlert(alertName); + } + } + + const { addMessageListener, sendAsyncMessage } = this; + + addMessageListener("mock-alert-service:unregister", () => { + closeAllNotifications(); + activeNotifications = null; + registrar.unregisterFactory(MOCK_ALERTS_CID, mockAlertsService); + sendAsyncMessage("mock-alert-service:unregistered"); + }); + + addMessageListener( + "mock-alert-service:click-notifications", + clickNotifications + ); + + addMessageListener( + "mock-alert-service:close-notifications", + closeAllNotifications + ); + + sendAsyncMessage("mock-alert-service:registered"); +} + +const MockAlertsService = { + async register() { + if (this._chromeScript) { + throw new Error("MockAlertsService already registered"); + } + this._chromeScript = SpecialPowers.loadChromeScript( + mockServicesChromeScript + ); + await this._chromeScript.promiseOneMessage("mock-alert-service:registered"); + }, + async unregister() { + if (!this._chromeScript) { + throw new Error("MockAlertsService not registered"); + } + this._chromeScript.sendAsyncMessage("mock-alert-service:unregister"); + return this._chromeScript + .promiseOneMessage("mock-alert-service:unregistered") + .then(() => { + this._chromeScript.destroy(); + this._chromeScript = null; + }); + }, + async clickNotifications() { + // Most implementations of the nsIAlertsService automatically close upon click. + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:click-notifications", + true + ); + }, + async clickNotificationsWithoutClose() { + // The implementation on macOS does not automatically close the notification. + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:click-notifications", + false + ); + }, + async closeNotifications() { + await this._chromeScript.sendAsyncMessage( + "mock-alert-service:close-notifications" + ); + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js new file mode 100644 index 0000000000..73b98b68ae --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_unlimitedStorage.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported checkSitePermissions */ + +const { Services } = SpecialPowers; +const { NetUtil } = SpecialPowers.Cu.import( + "resource://gre/modules/NetUtil.jsm", + {} +); + +function checkSitePermissions(uuid, expectedPermAction, assertMessage) { + if (!uuid) { + throw new Error( + "checkSitePermissions should not be called with an undefined uuid" + ); + } + + const baseURI = NetUtil.newURI(`moz-extension://${uuid}/`); + const principal = Services.scriptSecurityManager.createContentPrincipal( + baseURI, + {} + ); + + const sitePermissions = { + webextUnlimitedStorage: Services.perms.testPermissionFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ), + indexedDB: Services.perms.testPermissionFromPrincipal( + principal, + "indexedDB" + ), + persistentStorage: Services.perms.testPermissionFromPrincipal( + principal, + "persistent-storage" + ), + }; + + for (const [sitePermissionName, actualPermAction] of Object.entries( + sitePermissions + )) { + is( + actualPermAction, + expectedPermAction, + `The extension "${sitePermissionName}" SitePermission ${assertMessage} as expected` + ); + } +} diff --git a/toolkit/components/extensions/test/mochitest/head_webrequest.js b/toolkit/components/extensions/test/mochitest/head_webrequest.js new file mode 100644 index 0000000000..f6c6530e41 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/head_webrequest.js @@ -0,0 +1,482 @@ +"use strict"; + +let commonEvents = { + onBeforeRequest: [{ urls: ["<all_urls>"] }, ["blocking"]], + onBeforeSendHeaders: [ + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"], + ], + onSendHeaders: [{ urls: ["<all_urls>"] }, ["requestHeaders"]], + onBeforeRedirect: [{ urls: ["<all_urls>"] }], + onHeadersReceived: [ + { urls: ["<all_urls>"] }, + ["blocking", "responseHeaders"], + ], + // Auth tests will need to set their own events object + // "onAuthRequired": [{urls: ["<all_urls>"]}, ["blocking", "responseHeaders"]], + onResponseStarted: [{ urls: ["<all_urls>"] }], + onCompleted: [{ urls: ["<all_urls>"] }, ["responseHeaders"]], + onErrorOccurred: [{ urls: ["<all_urls>"] }], +}; + +function background(events) { + const IP_PATTERN = /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/; + + let expect; + let ignore; + let defaultOrigin; + let watchAuth = Object.keys(events).includes("onAuthRequired"); + let expectedIp = null; + + browser.test.onMessage.addListener((msg, expected) => { + if (msg !== "set-expected") { + return; + } + expect = expected.expect; + defaultOrigin = expected.origin; + ignore = expected.ignore; + let promises = []; + // Initialize some stuff we'll need in the tests. + for (let entry of Object.values(expect)) { + // a place for the test infrastructure to store some state. + entry.test = {}; + // Each entry in expected gets a Promise that will be resolved in the + // last event for that entry. This will either be onCompleted, or the + // last entry if an events list was provided. + promises.push( + new Promise(resolve => { + entry.test.resolve = resolve; + }) + ); + // If events was left undefined, we're expecting all normal events we're + // listening for, exclude onBeforeRedirect and onErrorOccurred + if (entry.events === undefined) { + entry.events = Object.keys(events).filter( + name => name != "onErrorOccurred" && name != "onBeforeRedirect" + ); + } + if (entry.optional_events === undefined) { + entry.optional_events = []; + } + } + // When every expected entry has finished our test is done. + Promise.all(promises).then(() => { + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("continue"); + }); + + // Retrieve the per-file/test expected values. + function getExpected(details) { + let url = new URL(details.url); + let filename = url.pathname.split("/").pop(); + if (ignore && ignore.includes(filename)) { + return; + } + let expected = expect[filename]; + if (!expected) { + browser.test.fail(`unexpected request ${filename}`); + return; + } + // Save filename for redirect verification. + expected.test.filename = filename; + return expected; + } + + // Process any test header modifications that can happen in request or response phases. + // If a test includes headers, it needs a complete header object, no undefined + // objects even if empty: + // request: { + // add: {"HeaderName": "value",}, + // modify: {"HeaderName": "value",}, + // remove: ["HeaderName",], + // }, + // response: { + // add: {"HeaderName": "value",}, + // modify: {"HeaderName": "value",}, + // remove: ["HeaderName",], + // }, + function processHeaders(phase, expected, details) { + // This should only happen once per phase [request|response]. + browser.test.assertFalse( + !!expected.test[phase], + `First processing of headers for ${phase}` + ); + expected.test[phase] = true; + + let headers = details[`${phase}Headers`]; + browser.test.assertTrue( + Array.isArray(headers), + `${phase}Headers array present` + ); + + let { add, modify, remove } = expected.headers[phase]; + + for (let name in add) { + browser.test.assertTrue( + !headers.find(h => h.name === name), + `header ${name} to be added not present yet in ${phase}Headers` + ); + let header = { name: name }; + if (name.endsWith("-binary")) { + header.binaryValue = Array.from(add[name], c => c.charCodeAt(0)); + } else { + header.value = add[name]; + } + headers.push(header); + } + + let modifiedAny = false; + for (let header of headers) { + if (header.name.toLowerCase() in modify) { + header.value = modify[header.name.toLowerCase()]; + modifiedAny = true; + } + } + browser.test.assertTrue( + modifiedAny, + `at least one ${phase}Headers element to modify` + ); + + let deletedAny = false; + for (let j = headers.length; j-- > 0; ) { + if (remove.includes(headers[j].name.toLowerCase())) { + headers.splice(j, 1); + deletedAny = true; + } + } + browser.test.assertTrue( + deletedAny, + `at least one ${phase}Headers element to delete` + ); + + return headers; + } + + // phase is request or response. + function checkHeaders(phase, expected, details) { + if (!/^https?:/.test(details.url)) { + return; + } + + let headers = details[`${phase}Headers`]; + browser.test.assertTrue( + Array.isArray(headers), + `valid ${phase}Headers array` + ); + + let { add, modify, remove } = expected.headers[phase]; + for (let name in add) { + let value = headers.find(h => h.name.toLowerCase() === name.toLowerCase()) + .value; + browser.test.assertEq( + value, + add[name], + `header ${name} correctly injected in ${phase}Headers` + ); + } + + for (let name in modify) { + let value = headers.find(h => h.name.toLowerCase() === name.toLowerCase()) + .value; + browser.test.assertEq( + value, + modify[name], + `header ${name} matches modified value` + ); + } + + for (let name of remove) { + let found = headers.find( + h => h.name.toLowerCase() === name.toLowerCase() + ); + browser.test.assertFalse( + !!found, + `deleted header ${name} still found in ${phase}Headers` + ); + } + } + + let listeners = { + onBeforeRequest(expected, details, result) { + // Save some values to test request consistency in later events. + browser.test.assertTrue( + details.tabId !== undefined, + `tabId ${details.tabId}` + ); + browser.test.assertTrue( + details.requestId !== undefined, + `requestId ${details.requestId}` + ); + // Validate requestId if it's already set, this happens with redirects. + if (expected.test.requestId !== undefined) { + browser.test.assertEq( + "string", + typeof expected.test.requestId, + `requestid ${expected.test.requestId} is string` + ); + browser.test.assertEq( + "string", + typeof details.requestId, + `requestid ${details.requestId} is string` + ); + browser.test.assertEq( + "number", + typeof parseInt(details.requestId, 10), + "parsed requestid is number" + ); + browser.test.assertEq( + expected.test.requestId, + details.requestId, + "redirects will keep the same requestId" + ); + } else { + // Save any values we want to validate in later events. + expected.test.requestId = details.requestId; + expected.test.tabId = details.tabId; + } + // Tests we don't need to do every event. + browser.test.assertTrue( + details.type.toUpperCase() in browser.webRequest.ResourceType, + `valid resource type ${details.type}` + ); + if (details.type == "main_frame") { + browser.test.assertEq( + 0, + details.frameId, + "frameId is zero when type is main_frame, see bug 1329299" + ); + } + }, + onBeforeSendHeaders(expected, details, result) { + if (expected.headers && expected.headers.request) { + result.requestHeaders = processHeaders("request", expected, details); + } + if (expected.redirect) { + browser.test.log(`${name} redirect request`); + result.redirectUrl = details.url.replace( + expected.test.filename, + expected.redirect + ); + } + }, + onBeforeRedirect() {}, + onSendHeaders(expected, details, result) { + if (expected.headers && expected.headers.request) { + checkHeaders("request", expected, details); + } + }, + onResponseStarted() {}, + onHeadersReceived(expected, details, result) { + let expectedStatus = expected.status || 200; + // If authentication is being requested we don't fail on the status code. + if (watchAuth && [401, 407].includes(details.statusCode)) { + expectedStatus = details.statusCode; + } + browser.test.assertEq( + expectedStatus, + details.statusCode, + `expected HTTP status received for ${details.url} ${details.statusLine}` + ); + if (expected.headers && expected.headers.response) { + result.responseHeaders = processHeaders("response", expected, details); + } + }, + onAuthRequired(expected, details, result) { + result.authCredentials = expected.authInfo; + }, + onCompleted(expected, details, result) { + // If we have already completed a GET request for this url, + // and it was found, we expect for the response to come fromCache. + // expected.cached may be undefined, force boolean. + if (typeof expected.cached === "boolean") { + let expectCached = + expected.cached && + details.method === "GET" && + details.statusCode != 404; + browser.test.assertEq( + expectCached, + details.fromCache, + "fromCache is correct" + ); + } + // We can only tell IPs for non-cached HTTP requests. + if (!details.fromCache && /^https?:/.test(details.url)) { + browser.test.assertTrue( + IP_PATTERN.test(details.ip), + `IP for ${details.url} looks IP-ish: ${details.ip}` + ); + + // We can't easily predict the IP ahead of time, so just make + // sure they're all consistent. + expectedIp = expectedIp || details.ip; + browser.test.assertEq( + expectedIp, + details.ip, + `correct ip for ${details.url}` + ); + } + if (expected.headers && expected.headers.response) { + checkHeaders("response", expected, details); + } + }, + onErrorOccurred(expected, details, result) { + if (expected.error) { + if (Array.isArray(expected.error)) { + browser.test.assertTrue( + expected.error.includes(details.error), + "expected error message received in onErrorOccurred" + ); + } else { + browser.test.assertEq( + expected.error, + details.error, + "expected error message received in onErrorOccurred" + ); + } + } + }, + }; + + function getListener(name) { + return details => { + let result = {}; + browser.test.log(`${name} ${details.requestId} ${details.url}`); + let expected = getExpected(details); + if (!expected) { + return result; + } + let expectedEvent = expected.events[0] == name; + if (expectedEvent) { + expected.events.shift(); + } else { + // e10s vs. non-e10s errors can end with either onCompleted or onErrorOccurred + expectedEvent = expected.optional_events.includes(name); + } + browser.test.assertTrue(expectedEvent, `received ${name}`); + browser.test.assertEq( + expected.type, + details.type, + "resource type is correct" + ); + browser.test.assertEq( + expected.origin || defaultOrigin, + details.originUrl, + "origin is correct" + ); + + if (name != "onBeforeRequest") { + // On events after onBeforeRequest, check the previous values. + browser.test.assertEq( + expected.test.requestId, + details.requestId, + "correct requestId" + ); + browser.test.assertEq( + expected.test.tabId, + details.tabId, + "correct tabId" + ); + } + try { + listeners[name](expected, details, result); + } catch (e) { + browser.test.fail(`unexpected webrequest failure ${name} ${e}`); + } + + if (expected.cancel && expected.cancel == name) { + browser.test.log(`${name} cancel request`); + browser.test.sendMessage("cancelled"); + result.cancel = true; + } + // If we've used up all the events for this test, resolve the promise. + // If something wrong happens and more events come through, there will be + // failures. + if (expected.events.length <= 0) { + expected.test.resolve(); + } + return result; + }; + } + + for (let [name, args] of Object.entries(events)) { + browser.test.log(`adding listener for ${name}`); + try { + browser.webRequest[name].addListener(getListener(name), ...args); + } catch (e) { + browser.test.assertTrue( + /\brequestBody\b/.test(e.message), + "Request body is unsupported" + ); + + // RequestBody is disabled in release builds. + if (!/\brequestBody\b/.test(e.message)) { + throw e; + } + + args.splice(args.indexOf("requestBody"), 1); + browser.webRequest[name].addListener(getListener(name), ...args); + } + } +} + +/* exported makeExtension */ + +function makeExtension(events = commonEvents) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${JSON.stringify(events)})`, + }); +} + +/* exported addStylesheet */ + +function addStylesheet(file) { + let link = document.createElement("link"); + link.setAttribute("rel", "stylesheet"); + link.setAttribute("href", file); + document.body.appendChild(link); +} + +/* exported addLink */ + +function addLink(file) { + let a = document.createElement("a"); + a.setAttribute("href", file); + a.setAttribute("target", "_blank"); + a.setAttribute("rel", "opener"); + document.body.appendChild(a); + return a; +} + +/* exported addImage */ + +function addImage(file) { + let img = document.createElement("img"); + img.setAttribute("src", file); + document.body.appendChild(img); +} + +/* exported addScript */ + +function addScript(file) { + let script = document.createElement("script"); + script.setAttribute("type", "text/javascript"); + script.setAttribute("src", file); + document + .getElementsByTagName("head") + .item(0) + .appendChild(script); +} + +/* exported addFrame */ + +function addFrame(file) { + let frame = document.createElement("iframe"); + frame.setAttribute("width", "200"); + frame.setAttribute("height", "200"); + frame.setAttribute("src", file); + document.body.appendChild(frame); +} diff --git a/toolkit/components/extensions/test/mochitest/hsts.sjs b/toolkit/components/extensions/test/mochitest/hsts.sjs new file mode 100644 index 0000000000..636f331882 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/hsts.sjs @@ -0,0 +1,8 @@ +function handleRequest(request, response) { + let page = "<!DOCTYPE html><html><body><p>HSTS page</p></body></html>"; + response.setStatusLine(request.httpVersion, "200", "OK"); + response.setHeader("Strict-Transport-Security", "max-age=60"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Content-Length", page.length + "", false); + response.write(page); +} diff --git a/toolkit/components/extensions/test/mochitest/mochitest-common.ini b/toolkit/components/extensions/test/mochitest/mochitest-common.ini new file mode 100644 index 0000000000..2e32a951e2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-common.ini @@ -0,0 +1,206 @@ +[DEFAULT] +support-files = + chrome_cleanup_script.js + file_WebNavigation_page1.html + file_WebNavigation_page2.html + file_WebNavigation_page3.html + file_WebRequest_page3.html + file_contains_img.html + file_contains_iframe.html + file_green.html + file_contentscript_activeTab.html + file_contentscript_activeTab2.html + file_contentscript_iframe.html + file_image_bad.png + file_image_good.png + file_image_great.png + file_image_redirect.png + file_indexedDB.html + file_mixed.html + file_remote_frame.html + file_sample.html + file_sample.txt + file_sample.txt^headers^ + file_script_bad.js + file_script_good.js + file_script_redirect.js + file_script_xhr.js + file_serviceWorker.html + file_simple_sandboxed_frame.html + file_simple_sandboxed_subframe.html + file_simple_xhr.html + file_simple_xhr_frame.html + file_simple_xhr_frame2.html + file_streamfilter.txt + file_style_bad.css + file_style_good.css + file_style_redirect.css + file_third_party.html + file_to_drawWindow.html + file_webNavigation_clientRedirect.html + file_webNavigation_clientRedirect_httpHeaders.html + file_webNavigation_clientRedirect_httpHeaders.html^headers^ + file_webNavigation_frameClientRedirect.html + file_webNavigation_frameRedirect.html + file_webNavigation_manualSubframe.html + file_webNavigation_manualSubframe_page1.html + file_webNavigation_manualSubframe_page2.html + file_with_about_blank.html + file_with_xorigin_frame.html + head.js + head_cookies.js + head_notifications.js + head_unlimitedStorage.js + head_webrequest.js + hsts.sjs + mochitest_console.js + oauth.html + redirect_auto.sjs + redirection.sjs + return_headers.sjs + serviceWorker.js + slow_response.sjs + webrequest_worker.js + !/dom/tests/mochitest/geolocation/network_geolocation.sjs + !/toolkit/components/passwordmgr/test/authenticate.sjs + file_redirect_data_uri.html + file_redirect_cors_bypass.html + file_tabs_permission_page1.html + file_tabs_permission_page2.html +prefs = + security.mixed_content.upgrade_display_content=false + browser.chrome.guess_favicon=true + +[test_ext_activityLog.html] +skip-if = + os == 'android' + tsan # Times out on TSan, bug 1612707 + xorigin # Inconsistent pass/fail in opt and debug +[test_ext_async_clipboard.html] +skip-if = toolkit == 'android' || tsan # near-permafail after landing bug 1270059: Bug 1523131. tsan: bug 1612707 +[test_ext_background_canvas.html] +[test_ext_background_page.html] +skip-if = (toolkit == 'android') # android doesn't have devtools +[test_ext_browsingData_indexedDB.html] +[test_ext_browsingData_localStorage.html] +[test_ext_browsingData_pluginData.html] +[test_ext_browsingData_serviceWorkers.html] +[test_ext_browsingData_settings.html] +[test_ext_canvas_resistFingerprinting.html] +[test_ext_clipboard.html] +skip-if = os == 'android' +[test_ext_clipboard_image.html] +skip-if = headless # Bug 1405872 +[test_ext_contentscript_about_blank.html] +skip-if = os == 'android' # bug 1369440 +[test_ext_contentscript_activeTab.html] +skip-if = os == 'android' || fission +[test_ext_contentscript_cache.html] +skip-if = (os == 'linux' && debug) || (toolkit == 'android' && debug) # bug 1348241 +fail-if = xorigin # TypeError: can't access property "staticScripts", ext is undefined - Should not throw any errors +[test_ext_contentscript_canvas.html] +skip-if = (os == 'android') || (verify && debug && (os == 'linux')) # Bug 1617062 +[test_ext_contentscript_devtools_metadata.html] +[test_ext_contentscript_fission_frame.html] +[test_ext_contentscript_incognito.html] +skip-if = os == 'android' # Android does not support multiple windows. +[test_ext_contentscript_permission.html] +skip-if = tsan # Times out on TSan, bug 1612707 +[test_ext_cookies.html] +skip-if = os == 'android' || tsan # Times out on TSan intermittently, bug 1615184; not supported on Android yet +[test_ext_cookies_containers.html] +[test_ext_cookies_expiry.html] +[test_ext_cookies_first_party.html] +[test_ext_cookies_incognito.html] +skip-if = os == 'android' # Bug 1513544 Android does not support multiple windows. +[test_ext_cookies_permissions_bad.html] +[test_ext_cookies_permissions_good.html] +[test_ext_downloads_download.html] +[test_ext_embeddedimg_iframe_frameAncestors.html] +[test_ext_exclude_include_globs.html] +[test_ext_external_messaging.html] +[test_ext_generate.html] +[test_ext_geolocation.html] +skip-if = os == 'android' # Android support Bug 1336194 +[test_ext_identity.html] +skip-if = os == 'android' || tsan # unsupported. tsan: bug 1612707 +[test_ext_idle.html] +skip-if = tsan # Times out on TSan, bug 1612707 +[test_ext_inIncognitoContext_window.html] +skip-if = os == 'android' # Android does not support multiple windows. +[test_ext_listener_proxies.html] +[test_ext_new_tab_processType.html] +skip-if = verify && debug && (os == 'linux' || os == 'mac') +[test_ext_notifications.html] +skip-if = os == 'android' # Not supported on Android yet +[test_ext_protocolHandlers.html] +skip-if = (toolkit == 'android') # bug 1342577 +[test_ext_redirect_jar.html] +skip-if = os == 'win' && (debug || asan) # Bug 1563440 +[test_ext_request_urlClassification.html] +skip-if = os == 'android' # Bug 1615427 +[test_ext_runtime_connect.html] +[test_ext_runtime_connect_twoway.html] +[test_ext_runtime_connect2.html] +[test_ext_runtime_disconnect.html] +[test_ext_sendmessage_doublereply.html] +[test_ext_sendmessage_frameId.html] +[test_ext_sendmessage_no_receiver.html] +[test_ext_sendmessage_reply.html] +[test_ext_sendmessage_reply2.html] +skip-if = os == 'android' +[test_ext_storage_manager_capabilities.html] +skip-if = xorigin # JavaScript Error: "SecurityError: Permission denied to access property "wrappedJSObject" on cross-origin object" {file: "https://example.com/tests/SimpleTest/TestRunner.js" line: 157} +scheme=https +[test_ext_storage_smoke_test.html] +[test_ext_streamfilter_multiple.html] +skip-if = + !debug # Bug 1628642 + os == 'linux' # Bug 1628642 +[test_ext_streamfilter_processswitch.html] +[test_ext_subframes_privileges.html] +skip-if = os == 'android' || verify # bug 1489771 +[test_ext_tabs_captureTab.html] +[test_ext_tabs_query_popup.html] +[test_ext_tabs_permissions.html] +[test_ext_tabs_sendMessage.html] +[test_ext_test.html] +[test_ext_unlimitedStorage.html] +skip-if = os == 'android' +[test_ext_unlimitedStorage_legacy_persistent_indexedDB.html] +# IndexedDB persistent storage mode is not allowed on Fennec from a non-chrome privileged code +# (it has only been enabled for apps and privileged code). See Bug 1119462 for additional info. +skip-if = os == 'android' +[test_ext_web_accessible_resources.html] +skip-if = (os == 'android' && debug) || fission || (os == "linux" && bits == 64) # bug 1397615, bug 1588284, bug 1618231 +[test_ext_web_accessible_incognito.html] +skip-if = (os == 'android') || fission # Crashes intermittently: @ mozilla::dom::BrowsingContext::CreateFromIPC(mozilla::dom::BrowsingContext::IPCInitializer&&, mozilla::dom::BrowsingContextGroup*, mozilla::dom::ContentParent*), bug 1588284, bug 1397615 and bug 1513544 +[test_ext_webnavigation.html] +skip-if = (os == 'android' && debug) # bug 1397615 +[test_ext_webnavigation_filters.html] +skip-if = (os == 'android' && debug) || (verify && (os == 'linux' || os == 'mac')) # bug 1397615 +[test_ext_webnavigation_incognito.html] +skip-if = os == 'android' # bug 1513544 +[test_ext_webrequest_and_proxy_filter.html] +[test_ext_webrequest_auth.html] +skip-if = os == 'android' +[test_ext_webrequest_background_events.html] +[test_ext_webrequest_basic.html] +skip-if = + os == 'android' && debug # bug 1397615 + tsan # bug 1612707 + xorigin # JavaScript Error: "SecurityError: Permission denied to access property "wrappedJSObject" on cross-origin object" {file: "http://mochi.false-test:8888/tests/SimpleTest/TestRunner.js" line: 157}] +[test_ext_webrequest_errors.html] +skip-if = tsan +[test_ext_webrequest_filter.html] +skip-if = os == 'android' && debug || tsan # bug 1452348. tsan: bug 1612707 +[test_ext_webrequest_frameId.html] +skip-if = (webrender && os == 'linux') # Bug 1482983 caused by Bug 1480951 +[test_ext_webrequest_hsts.html] +skip-if = os == 'android' || os == 'linux' || os == 'mac' #Bug 1605515 +[test_ext_webrequest_upgrade.html] +[test_ext_webrequest_upload.html] +skip-if = os == 'android' # Currently fails in emulator tests +[test_ext_webrequest_redirect_bypass_cors.html] +[test_ext_webrequest_redirect_data_uri.html] +[test_ext_window_postMessage.html] diff --git a/toolkit/components/extensions/test/mochitest/mochitest-remote.ini b/toolkit/components/extensions/test/mochitest/mochitest-remote.ini new file mode 100644 index 0000000000..2828eb2182 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest-remote.ini @@ -0,0 +1,8 @@ +[DEFAULT] +tags = webextensions remote-webextensions +skip-if = !e10s || (os == 'android') # Bug 1620091: disable on android until extension process is done +prefs = + extensions.webextensions.remote=true + +[test_verify_remote_mode.html] +[include:mochitest-common.ini] diff --git a/toolkit/components/extensions/test/mochitest/mochitest.ini b/toolkit/components/extensions/test/mochitest/mochitest.ini new file mode 100644 index 0000000000..4612cac657 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest.ini @@ -0,0 +1,12 @@ +[DEFAULT] +tags = webextensions in-process-webextensions +prefs = + extensions.webextensions.remote=false +dupe-manifest = true + +[test_verify_non_remote_mode.html] +[test_ext_storage_cleanup.html] +# Bug 1426514 storage_cleanup: clearing localStorage fails with oop + +[include:mochitest-common.ini] +skip-if = os == 'win' # Windows WebExtensions always run OOP diff --git a/toolkit/components/extensions/test/mochitest/mochitest_console.js b/toolkit/components/extensions/test/mochitest/mochitest_console.js new file mode 100644 index 0000000000..e4be8acd69 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/mochitest_console.js @@ -0,0 +1,53 @@ +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { addMessageListener, sendAsyncMessage } = this; + +// Much of the console monitoring code is copied from TestUtils but simplified +// to our needs. +function monitorConsole(msgs) { + function msgMatches(msg, pat) { + for (let k in pat) { + if (!(k in msg)) { + return false; + } + if (pat[k] instanceof RegExp && typeof msg[k] === "string") { + if (!pat[k].test(msg[k])) { + return false; + } + } else if (msg[k] !== pat[k]) { + return false; + } + } + return true; + } + + let counter = 0; + function listener(msg) { + if (msgMatches(msg, msgs[counter])) { + counter++; + } + } + addMessageListener("waitForConsole", () => { + sendAsyncMessage("consoleDone", { + ok: counter >= msgs.length, + message: `monitorConsole | messages left expected at least ${msgs.length} got ${counter}`, + }); + Services.console.unregisterListener(listener); + }); + + Services.console.registerListener(listener); +} + +addMessageListener("consoleStart", messages => { + for (let msg of messages) { + // Message might be a RegExp object from a different compartment, but + // instanceof RegExp will fail. If we have an object, lets just make + // sure. + let message = msg.message; + if (typeof message == "object" && !(message instanceof RegExp)) { + msg.message = new RegExp(message); + } + } + monitorConsole(messages); +}); diff --git a/toolkit/components/extensions/test/mochitest/oauth.html b/toolkit/components/extensions/test/mochitest/oauth.html new file mode 100644 index 0000000000..8b9b1d65ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/oauth.html @@ -0,0 +1,26 @@ +<!DOCTYPE html> +<html> +<head> + <script> + "use strict"; + + onload = () => { + let url = new URL(location); + if (url.searchParams.get("post")) { + let server_redirect = `${url.searchParams.get("server_uri")}?redirect_uri=${encodeURIComponent(url.searchParams.get("redirect_uri"))}`; + let form = document.forms.testform; + form.setAttribute("action", server_redirect); + form.submit(); + } else { + let end = new URL(url.searchParams.get("redirect_uri")); + end.searchParams.set("access_token", "here ya go"); + location.href = end.href; + } + }; + </script> +</head> +<body> + <form name="testform" action="" method="POST"> + </form> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/redirect_auto.sjs b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs new file mode 100644 index 0000000000..27d249f022 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/redirect_auto.sjs @@ -0,0 +1,21 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + */ +Components.utils.importGlobalProperties(["URLSearchParams", "URL"]); + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + if (params.has("no_redirect")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); + } else { + if (request.method == "POST") { + response.setStatusLine(request.httpVersion, 303, "Redirected"); + } else { + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + } + let url = new URL(params.get("redirect_uri") || params.get("default_redirect")); + url.searchParams.set("access_token", "here ya go"); + response.setHeader("Location", url.href); + } +} diff --git a/toolkit/components/extensions/test/mochitest/redirection.sjs b/toolkit/components/extensions/test/mochitest/redirection.sjs new file mode 100644 index 0000000000..370ecd213f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/redirection.sjs @@ -0,0 +1,4 @@ +function handleRequest(aRequest, aResponse) { + aResponse.setStatusLine(aRequest.httpVersion, 302); + aResponse.setHeader("Location", "./dummy_page.html"); +} diff --git a/toolkit/components/extensions/test/mochitest/return_headers.sjs b/toolkit/components/extensions/test/mochitest/return_headers.sjs new file mode 100644 index 0000000000..54e2e5fb4d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/return_headers.sjs @@ -0,0 +1,20 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ft=javascript sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported handleRequest */ + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + // Why on earth is this a nsISimpleEnumerator... + let enumerator = request.headers; + while (enumerator.hasMoreElements()) { + let header = enumerator.getNext().data; + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +} + diff --git a/toolkit/components/extensions/test/mochitest/serviceWorker.js b/toolkit/components/extensions/test/mochitest/serviceWorker.js new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/serviceWorker.js diff --git a/toolkit/components/extensions/test/mochitest/slow_response.sjs b/toolkit/components/extensions/test/mochitest/slow_response.sjs new file mode 100644 index 0000000000..290d6ca1de --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/slow_response.sjs @@ -0,0 +1,55 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +/* eslint-disable no-unused-vars */ + +Cu.import("resource://gre/modules/AppConstants.jsm"); + +const DELAY = AppConstants.DEBUG ? 4000 : 800; + +let nsTimer = Components.Constructor("@mozilla.org/timer;1", "nsITimer", "initWithCallback"); + +let timer; +function delay() { + return new Promise(resolve => { + timer = nsTimer(resolve, DELAY, Ci.nsITimer.TYPE_ONE_SHOT); + }); +} + +const PARTS = [ + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body>`, + "Lorem ipsum dolor sit amet, <br>", + "consectetur adipiscing elit, <br>", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>", + "Excepteur sint occaecat cupidatat non proident, <br>", + "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>", + ` + </body> + </html>`, +]; + +async function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + + await delay(); + + for (let part of PARTS) { + response.write(`${part}\n`); + await delay(); + } + + response.finish(); +} + diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html new file mode 100644 index 0000000000..42950c50ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_data_uri.html @@ -0,0 +1,104 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test content script matching a data: URI</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/> +</head> +<body> + +<script> +"use strict"; + +add_task(async function test_contentscript_data_uri() { + const target = ExtensionTestUtils.loadExtension({ + files: { + "page.html": `<!DOCTYPE html> + <meta charset="utf-8"> + <iframe id="inherited" src="data:text/html;charset=utf-8,inherited"></iframe> + `, + }, + background() { + browser.test.sendMessage("page", browser.runtime.getURL("page.html")); + }, + }); + + const scripts = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + content_scripts: [{ + all_frames: true, + matches: ["<all_urls>"], + run_at: "document_start", + css: ["all_urls.css"], + js: ["all_urls.js"], + }], + }, + files: { + "all_urls.css": ` + body { background: yellow; } + `, + "all_urls.js": function() { + document.body.style.color = "red"; + browser.test.assertTrue(location.protocol !== "data:", + `Matched document not a data URI: ${location.href}`); + }, + }, + background() { + browser.webNavigation.onCompleted.addListener(({url, frameId}) => { + browser.test.log(`Document loading complete: ${url}`); + if (frameId === 0) { + browser.test.sendMessage("tab-ready", url); + } + }); + }, + }); + + await target.startup(); + await scripts.startup(); + + // Test extension page with a data: iframe. + const page = await target.awaitMessage("page"); + + // Hold on to the tab by the browser, as extension loads are COOP loads, and + // will break WindowProxy references. + let win = window.open(); + const browserFrame = win.browsingContext.embedderElement; + win.location.href = page; + + await scripts.awaitMessage("tab-ready"); + win = browserFrame.contentWindow; + is(win.location.href, page, "Extension page loaded into a tab"); + is(win.document.readyState, "complete", "Page finished loading"); + + const iframe = win.document.getElementById("inherited").contentWindow; + is(iframe.document.readyState, "complete", "iframe finished loading"); + + const style1 = iframe.getComputedStyle(iframe.document.body); + is(style1.color, "rgb(0, 0, 0)", "iframe text color is unmodified"); + is(style1.backgroundColor, "rgba(0, 0, 0, 0)", "iframe background unmodified"); + + // Test extension tab navigated to a data: URI. + const data = "data:text/html;charset=utf-8,also-inherits"; + win.location.href = data; + + await scripts.awaitMessage("tab-ready"); + win = browserFrame.contentWindow; + is(win.location.href, data, "Extension tab navigated to a data: URI"); + is(win.document.readyState, "complete", "Tab finished loading"); + + const style2 = win.getComputedStyle(win.document.body); + is(style2.color, "rgb(0, 0, 0)", "Tab text color is unmodified"); + is(style2.backgroundColor, "rgba(0, 0, 0, 0)", "Tab background unmodified"); + + win.close(); + await target.unload(); + await scripts.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html new file mode 100644 index 0000000000..198b8e85cf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_telemetry.html @@ -0,0 +1,64 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for telemetry for content script injection</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/> +</head> +<body> + +<script> +"use strict"; + +const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS"; + +add_task(async function test_contentscript_telemetry() { + // Turn on telemetry and reset it to the previous state once the test is completed. + const telemetryCanRecordBase = SpecialPowers.Services.telemetry.canRecordBase; + SpecialPowers.Services.telemetry.canRecordBase = true; + SimpleTest.registerCleanupFunction(() => { + SpecialPowers.Services.telemetry.canRecordBase = telemetryCanRecordBase; + }); + + function background() { + browser.test.onMessage.addListener(() => { + browser.tabs.executeScript({code: 'browser.test.sendMessage("content-script-run");'}); + }); + } + + let extensionData = { + manifest: { + permissions: ["<all_urls>"], + }, + background, + }; + + let win = window.open("http://example.com/"); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let histogram = SpecialPowers.Services.telemetry.getHistogramById(HISTOGRAM); + histogram.clear(); + is(histogram.snapshot().sum, 0, + `No data recorded for histogram: ${HISTOGRAM}.`); + + await extension.startup(); + is(histogram.snapshot().sum, 0, + `No data recorded for histogram after startup: ${HISTOGRAM}.`); + + extension.sendMessage(); + await extension.awaitMessage("content-script-run"); + + let histogramSum = histogram.snapshot().sum; + ok(histogramSum > 0, + `Data recorded for first extension for histogram: ${HISTOGRAM}.`); + + win.close(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html new file mode 100644 index 0000000000..40403dea2b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_contentscript_unrecognizedprop_warning.html @@ -0,0 +1,80 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script unrecognized property on manifest</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const BASE = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest"; + +add_task(async function test_contentscript() { + function background() { + browser.runtime.onMessage.addListener(async (msg) => { + if (msg == "loaded") { + // NOTE: we're removing the tab from here because doing a win.close() + // from the chrome test code is raising a "TypeError: can't access + // dead object" exception. + let tabs = await browser.tabs.query({active: true, currentWindow: true}); + await browser.tabs.remove(tabs[0].id); + + browser.test.notifyPass("content-script-loaded"); + } + }); + } + + function contentScript() { + chrome.runtime.sendMessage("loaded"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + "unrecognized_property": "with-a-random-value", + }, + ], + }, + background, + + files: { + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [{ + message: /Reading manifest: Warning processing content_scripts.*.unrecognized_property: An unexpected property was found/, + }]); + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + window.open(`${BASE}/file_sample.html`); + + await Promise.all([extension.awaitFinish("content-script-loaded")]); + info("test page loaded"); + + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html new file mode 100644 index 0000000000..530937c1ac --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_open.html @@ -0,0 +1,114 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for permissions</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_downloads_open_permission() { + function backgroundScript() { + browser.test.assertEq(browser.downloads.open, undefined, + "`downloads.open` permission is required."); + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); + +add_task(async function test_downloads_open_requires_user_interaction() { + async function backgroundScript() { + await browser.test.assertRejects( + browser.downloads.open(10), + "downloads.open may only be called from a user input handler", + "The error is informative."); + + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads", "downloads.open"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); + +add_task(async function downloads_open_invalid_id() { + async function pageScript() { + window.addEventListener("keypress", async function handler() { + try { + await browser.downloads.open(10); + browser.test.sendMessage("download-open.result", {success: true}); + } catch (e) { + browser.test.sendMessage("download-open.result", { + success: false, + error: e.message, + }); + } + window.removeEventListener("keypress", handler); + }); + + browser.test.sendMessage("page-ready"); + } + + let extensionData = { + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + files: { + "foo.txt": "It's the file called foo.txt.", + "page.html": `<html><head> + <script src="page.js"><\/script> + </head></html>`, + "page.js": pageScript, + }, + manifest: { + permissions: ["downloads", "downloads.open"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let url = await extension.awaitMessage("ready"); + let win = window.open(); + let browserFrame = win.browsingContext.embedderElement; + win.location.href = url; + await extension.awaitMessage("page-ready"); + + synthesizeKey("a", {}, browserFrame.contentWindow); + let result = await extension.awaitMessage("download-open.result"); + + is(result.success, false, "Opening download fails."); + is(result.error, "Invalid download id 10", "The error is informative."); + + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html new file mode 100644 index 0000000000..64cfcfd289 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_saveAs.html @@ -0,0 +1,257 @@ +<!doctype html> +<html> +<head> + <title>Test downloads.download() saveAs option</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const {FileUtils} = ChromeUtils.import("resource://gre/modules/FileUtils.jsm"); + +const PROMPTLESS_DOWNLOAD_PREF = "browser.download.useDownloadDir"; + +const DOWNLOAD_FILENAME = "file_download.nonext.txt"; +const DEFAULT_SUBDIR = "subdir"; + +// We need to be able to distinguish files downloaded by the file picker from +// files downloaded without it. +let pickerDir; +let pbPickerDir; // for incognito downloads +let defaultDir; + +add_task(async function setup() { + // Reset DownloadLastDir preferences in case other tests set them. + SpecialPowers.Services.obs.notifyObservers( + null, + "browser:purge-session-history" + ); + + // Set up temporary directories. + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + pickerDir = downloadDir.clone(); + pickerDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`Using file picker download directory ${pickerDir.path}`); + pbPickerDir = downloadDir.clone(); + pbPickerDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`Using private browsing file picker download directory ${pbPickerDir.path}`); + defaultDir = downloadDir.clone(); + defaultDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`Using default download directory ${defaultDir.path}`); + let subDir = defaultDir.clone(); + subDir.append(DEFAULT_SUBDIR); + subDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + isnot(pickerDir.path, defaultDir.path, + "Should be able to distinguish between files saved with or without the file picker"); + isnot(pickerDir.path, pbPickerDir.path, + "Should be able to distinguish between files saved in and out of private browsing mode"); + + await SpecialPowers.pushPrefEnv({"set": [ + ["browser.download.folderList", 2], + ["browser.download.dir", defaultDir.path], + ]}); + + SimpleTest.registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + pickerDir.remove(true); + pbPickerDir.remove(true); + defaultDir.remove(true); // This also removes DEFAULT_SUBDIR. + }); +}); + +add_task(async function test_downloads_saveAs() { + const pickerFile = pickerDir.clone(); + pickerFile.append(DOWNLOAD_FILENAME); + + const pbPickerFile = pbPickerDir.clone(); + pbPickerFile.append(DOWNLOAD_FILENAME); + + const defaultFile = defaultDir.clone(); + defaultFile.append(DOWNLOAD_FILENAME); + + const {MockFilePicker} = SpecialPowers; + MockFilePicker.init(window); + + function mockFilePickerCallback(expectedStartingDir, pickedFile) { + return fp => { + // Assert that the downloads API correctly sets the starting directory. + ok(fp.displayDirectory.equals(expectedStartingDir), "Got the expected FilePicker displayDirectory"); + + // Assert that the downloads API configures both default properties. + is(fp.defaultString, DOWNLOAD_FILENAME, "Got the expected FilePicker defaultString"); + is(fp.defaultExtension, "txt", "Got the expected FilePicker defaultExtension"); + + MockFilePicker.setFiles([pickedFile]); + }; + } + + function background() { + const url = URL.createObjectURL(new Blob(["file content"])); + browser.test.onMessage.addListener(async (filename, saveAs, isPrivate) => { + try { + let options = { + url, + filename, + incognito: isPrivate, + }; + // Only define the saveAs option if the argument was actually set + if (saveAs !== undefined) { + options.saveAs = saveAs; + } + let id = await browser.downloads.download(options); + browser.downloads.onChanged.addListener(delta => { + if (delta.id == id && delta.state.current === "complete") { + browser.test.sendMessage("done", {ok: true, id}); + } + }); + } catch ({message}) { + browser.test.sendMessage("done", {ok: false, message}); + } + }); + browser.test.sendMessage("ready"); + } + + const manifest = { + background, + incognitoOverride: "spanning", + manifest: {permissions: ["downloads"]}, + }; + const extension = ExtensionTestUtils.loadExtension(manifest); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // options should have the following properties: + // saveAs (Boolean or undefined) + // isPrivate (Boolean) + // fileName (string) + // expectedStartingDir (nsIFile) + // destinationFile (nsIFile) + async function testExpectFilePicker(options) { + ok(!options.destinationFile.exists(), "the file should have been cleaned up properly previously"); + + MockFilePicker.showCallback = mockFilePickerCallback( + options.expectedStartingDir, + options.destinationFile + ); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + extension.sendMessage(options.fileName, options.saveAs, options.isPrivate); + let result = await extension.awaitMessage("done"); + ok(result.ok, `downloads.download() works with saveAs=${options.saveAs}`); + + ok(options.destinationFile.exists(), "the file exists."); + is(options.destinationFile.fileSize, 12, "downloaded file is the correct size"); + options.destinationFile.remove(false); + MockFilePicker.reset(); + + // Test the user canceling the save dialog. + MockFilePicker.returnValue = MockFilePicker.returnCancel; + + extension.sendMessage(options.fileName, options.saveAs, options.isPrivate); + result = await extension.awaitMessage("done"); + + ok(!result.ok, "download rejected if the user cancels the dialog"); + is(result.message, "Download canceled by the user", "with the correct message"); + ok(!options.destinationFile.exists(), "file was not downloaded"); + MockFilePicker.reset(); + } + + async function testNoFilePicker(saveAs) { + ok(!defaultFile.exists(), "the file should have been cleaned up properly previously"); + + extension.sendMessage(DOWNLOAD_FILENAME, saveAs, false); + let result = await extension.awaitMessage("done"); + ok(result.ok, `downloads.download() works with saveAs=${saveAs}`); + + ok(defaultFile.exists(), "the file exists."); + is(defaultFile.fileSize, 12, "downloaded file is the correct size"); + defaultFile.remove(false); + } + + info("Testing that saveAs=true uses the file picker as expected"); + let expectedStartingDir = defaultDir; + let fpOptions = { + saveAs: true, + isPrivate: false, + fileName: DOWNLOAD_FILENAME, + expectedStartingDir: expectedStartingDir, + destinationFile: pickerFile, + }; + await testExpectFilePicker(fpOptions); + + info("Testing that saveas=true reuses last file picker directory"); + fpOptions.expectedStartingDir = pickerDir; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=true in PB reuses last directory"); + let nonPBStartingDir = fpOptions.expectedStartingDir; + fpOptions.isPrivate = true; + fpOptions.destinationFile = pbPickerFile; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=true in PB uses a separate last directory"); + fpOptions.expectedStartingDir = pbPickerDir; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=true in Permanent PB mode ignores the incognito option"); + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + fpOptions.isPrivate = false; + fpOptions.expectedStartingDir = pbPickerDir; + await testExpectFilePicker(fpOptions); + + info("Testing that saveas=true reuses the non-PB last directory after private download"); + await SpecialPowers.popPrefEnv(); + fpOptions.isPrivate = false; + fpOptions.expectedStartingDir = nonPBStartingDir; + fpOptions.destinationFile = pickerFile; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=true does not reuse last directory when filename contains a path separator"); + fpOptions.fileName = DEFAULT_SUBDIR + "/" + DOWNLOAD_FILENAME; + let destinationFile = defaultDir.clone(); + destinationFile.append(DEFAULT_SUBDIR); + fpOptions.expectedStartingDir = destinationFile.clone(); + destinationFile.append(DOWNLOAD_FILENAME); + fpOptions.destinationFile = destinationFile; + await testExpectFilePicker(fpOptions); + + info("Testing that saveAs=false does not use the file picker"); + fpOptions.saveAs = false; + await testNoFilePicker(fpOptions.saveAs); + + // When saveAs is not set, the behavior should be determined by the Firefox + // pref that normally determines whether the "Save As" prompt should be + // displayed. + info(`Testing that the file picker is used when saveAs is not specified ` + + `but ${PROMPTLESS_DOWNLOAD_PREF} is disabled`); + fpOptions.saveAs = undefined; + await SpecialPowers.pushPrefEnv({"set": [ + [PROMPTLESS_DOWNLOAD_PREF, false], + ]}); + await testExpectFilePicker(fpOptions); + + info(`Testing that the file picker is NOT used when saveAs is not ` + + `specified but ${PROMPTLESS_DOWNLOAD_PREF} is enabled`); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.pushPrefEnv({"set": [ + [PROMPTLESS_DOWNLOAD_PREF, true], + ]}); + await testNoFilePicker(fpOptions.saveAs); + + await extension.unload(); + MockFilePicker.cleanup(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html new file mode 100644 index 0000000000..b5fedee7ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_downloads_uniquify.html @@ -0,0 +1,116 @@ +<!doctype html> +<html> +<head> + <title>Test downloads.download() uniquify option</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const {FileUtils} = ChromeUtils.import("resource://gre/modules/FileUtils.jsm"); + +let directory; + +add_task(async function setup() { + directory = FileUtils.getDir("TmpD", ["downloads"]); + directory.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`Using download directory ${directory.path}`); + + await SpecialPowers.pushPrefEnv({"set": [ + ["browser.download.folderList", 2], + ["browser.download.dir", directory.path], + ]}); + + SimpleTest.registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + directory.remove(true); + }); +}); + +add_task(async function test_downloads_uniquify() { + const file = directory.clone(); + file.append("file_download.txt"); + + const unique = directory.clone(); + unique.append("file_download(1).txt"); + + const {MockFilePicker} = SpecialPowers; + MockFilePicker.init(window); + MockFilePicker.returnValue = MockFilePicker.returnOK; + + MockFilePicker.showCallback = fp => { + let file = directory.clone(); + file.append(fp.defaultString); + MockFilePicker.setFiles([file]); + }; + + function background() { + const url = URL.createObjectURL(new Blob(["file content"])); + browser.test.onMessage.addListener(async (filename, saveAs) => { + try { + let id = await browser.downloads.download({ + url, + filename, + saveAs, + conflictAction: "uniquify", + }); + browser.downloads.onChanged.addListener(delta => { + if (delta.id == id && delta.state.current === "complete") { + browser.test.sendMessage("done", {ok: true, id}); + } + }); + } catch ({message}) { + browser.test.sendMessage("done", {ok: false, message}); + } + }); + browser.test.sendMessage("ready"); + } + + const manifest = {background, manifest: {permissions: ["downloads"]}}; + const extension = ExtensionTestUtils.loadExtension(manifest); + + await extension.startup(); + await extension.awaitMessage("ready"); + + async function testUniquify(saveAs) { + info(`Testing conflictAction:"uniquify" with saveAs=${saveAs}`); + + ok(!file.exists(), "downloaded file should have been cleaned up before test ran"); + ok(!unique.exists(), "uniquified file should have been cleaned up before test ran"); + + // Test download without uniquify and create a conflicting file so we can + // test with uniquify. + extension.sendMessage("file_download.txt", saveAs); + let result = await extension.awaitMessage("done"); + ok(result.ok, "downloads.download() works with saveAs"); + + ok(file.exists(), "the file exists."); + is(file.fileSize, 12, "downloaded file is the correct size"); + + // Now that a conflicting file exists, test the uniquify behavior + extension.sendMessage("file_download.txt", saveAs); + result = await extension.awaitMessage("done"); + ok(result.ok, "downloads.download() works with saveAs and uniquify"); + + ok(unique.exists(), "the file exists."); + is(unique.fileSize, 12, "downloaded file is the correct size"); + + file.remove(false); + unique.remove(false); + } + await testUniquify(true); + await testUniquify(false); + + await extension.unload(); + MockFilePicker.cleanup(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html new file mode 100644 index 0000000000..47761784b1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_permissions.html @@ -0,0 +1,176 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for permissions</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function makeTest(manifestPermissions, optionalPermissions, checkFetch = true) { + return async function() { + function pageScript() { + /* global PERMISSIONS */ + /* eslint-disable mozilla/balanced-listeners */ + window.addEventListener("keypress", () => { + browser.permissions.request(PERMISSIONS).then(result => { + browser.test.sendMessage("request.result", result); + }, {once: true}); + }); + /* eslint-enable mozilla/balanced-listeners */ + + browser.test.onMessage.addListener(async msg => { + if (msg == "set-cookie") { + try { + await browser.cookies.set({ + url: "http://example.com/", + name: "COOKIE", + value: "NOM NOM", + }); + browser.test.sendMessage("set-cookie.result", {success: true}); + } catch (err) { + dump(`set cookie failed with ${err.message}\n`); + browser.test.sendMessage("set-cookie.result", + {success: false, message: err.message}); + } + } else if (msg == "remove") { + browser.permissions.remove(PERMISSIONS).then(result => { + browser.test.sendMessage("remove.result", result); + }); + } + }); + + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + + manifest: { + permissions: manifestPermissions, + optional_permissions: [...(optionalPermissions.permissions || []), + ...(optionalPermissions.origins || [])], + + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + js: ["content_script.js"], + }], + }, + + files: { + "content_script.js": async () => { + let url = new URL(window.location.pathname, "http://example.com/"); + fetch(url, {}).then(response => { + browser.test.sendMessage("fetch.result", response.ok); + }).catch(err => { + browser.test.sendMessage("fetch.result", false); + }); + }, + + "page.html": `<html><head> + <script src="page.js"><\/script> + </head></html>`, + + "page.js": `const PERMISSIONS = ${JSON.stringify(optionalPermissions)}; (${pageScript})();`, + }, + }); + + await extension.startup(); + + function call(method) { + extension.sendMessage(method); + return extension.awaitMessage(`${method}.result`); + } + + let base = window.location.href.replace(/^chrome:\/\/mochitests\/content/, + "http://mochi.test:8888"); + let file = new URL("file_sample.html", base); + + async function testContentScript() { + let win = window.open(file); + let result = await extension.awaitMessage("fetch.result"); + win.close(); + return result; + } + + let url = await extension.awaitMessage("ready"); + let win = window.open(); + let browserFrame = win.browsingContext.embedderElement; + win.location.href = url; + await extension.awaitMessage("page-ready"); + + // Using the cookies API from an extension page should fail + let result = await call("set-cookie"); + is(result.success, false, "setting cookie failed"); + if (manifestPermissions.includes("cookies")) { + ok(/^Permission denied/.test(result.message), + "setting cookie failed with an appropriate error due to missing host permission"); + } else { + ok(/browser\.cookies is undefined/.test(result.message), + "setting cookie failed since cookies API is not present"); + } + + // Making a cross-origin request from a content script should fail + if (checkFetch) { + result = await testContentScript(); + is(result, false, "fetch() failed from content script due to lack of host permission"); + } + + synthesizeKey("a", {}, browserFrame.contentWindow); + result = await extension.awaitMessage("request.result"); + is(result, true, "permissions.request() succeeded"); + + // Using the cookies API from an extension page should succeed + result = await call("set-cookie"); + is(result.success, true, "setting cookie succeeded"); + + // Making a cross-origin request from a content script should succeed + if (checkFetch) { + result = await testContentScript(); + is(result, true, "fetch() succeeded from content script due to lack of host permission"); + } + + // Now revoke our permissions + result = await call("remove"); + + // The cookies API should once again fail + result = await call("set-cookie"); + is(result.success, false, "setting cookie failed"); + + // As should the cross-origin request from a content script + if (checkFetch) { + result = await testContentScript(); + is(result, false, "fetch() failed from content script due to lack of host permission"); + } + + await extension.unload(); + }; +} + +add_task(function setup() { + // Don't bother with prompts in this test. + return SpecialPowers.pushPrefEnv({ + set: [["extensions.webextOptionalPermissionPrompts", false]], + }); +}); + +const ORIGIN = "*://example.com/"; +add_task(makeTest([], { + permissions: ["cookies"], + origins: [ORIGIN], +})); + +add_task(makeTest(["cookies"], {origins: [ORIGIN]})); +add_task(makeTest([ORIGIN], {permissions: ["cookies"]}, false)); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html new file mode 100644 index 0000000000..580ea5e793 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_trackingprotection.html @@ -0,0 +1,98 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +var {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm"); + +function tp_background(expectFail = true) { + fetch("https://tracking.example.com/example.txt").then(() => { + browser.test.assertTrue(!expectFail, "fetch received"); + browser.test.sendMessage("done"); + }, () => { + browser.test.assertTrue(expectFail, "fetch failure"); + browser.test.sendMessage("done"); + }); +} + +async function test_permission(permissions, expectFail) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + background: `(${tp_background})(${expectFail})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +} + +add_task(async function setup() { + await UrlClassifierTestUtils.addTestTrackers(); + await SpecialPowers.pushPrefEnv({ + set: [["privacy.trackingprotection.enabled", true]], + }); +}); + +// Fetch would be blocked with these tests +add_task(async function() { await test_permission([], true); }); +add_task(async function() { await test_permission(["http://*/"], true); }); +add_task(async function() { await test_permission(["http://*.example.com/"], true); }); +add_task(async function() { await test_permission(["http://localhost/*"], true); }); +// Fetch will not be blocked if the extension has host permissions. +add_task(async function() { await test_permission(["<all_urls>"], false); }); +add_task(async function() { await test_permission(["*://tracking.example.com/*"], false); }); + +add_task(async function test_contentscript() { + function contentScript() { + fetch("https://tracking.example.com/example.txt").then(() => { + browser.test.notifyPass("fetch received"); + }, () => { + browser.test.notifyFail("fetch failure"); + }); + } + + let extensionData = { + manifest: { + permissions: ["*://tracking.example.com/*"], + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }; + const url = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_sample.html"; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let win = window.open(url); + await extension.awaitFinish(); + win.close(); + await extension.unload(); +}); + +add_task(async function teardown() { + UrlClassifierTestUtils.cleanupTestTrackers(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html new file mode 100644 index 0000000000..a06709d807 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webnavigation_resolved_urls.html @@ -0,0 +1,81 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function webnav_unresolved_uri_on_expected_URI_scheme() { + function background() { + let checkURLs; + + browser.webNavigation.onCompleted.addListener(async msg => { + if (checkURLs.length) { + let expectedURL = checkURLs.shift(); + browser.test.assertEq(expectedURL, msg.url, "Got the expected URL"); + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("next"); + } + }); + + browser.test.onMessage.addListener((name, urls) => { + if (name == "checkURLs") { + checkURLs = urls; + } + }); + + browser.test.sendMessage("ready", browser.runtime.getURL("/tab.html")); + } + + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background, + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + </html> + `, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let checkURLs = [ + "resource://gre/modules/Services.jsm", + "chrome://mochikit/content/tests/SimpleTest/SimpleTest.js", + "about:mozilla", + ]; + + let tabURL = await extension.awaitMessage("ready"); + checkURLs.push(tabURL); + + extension.sendMessage("checkURLs", checkURLs); + + for (let url of checkURLs) { + window.open(url); + await extension.awaitMessage("next"); + } + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html new file mode 100644 index 0000000000..a9dfb0a902 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_background_events.html @@ -0,0 +1,94 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const {webrequest_test} = ChromeUtils.import(SimpleTest.getTestFileURL("webrequest_test.jsm")); +let {testFetch, testXHR} = webrequest_test; + +// Here we test that any requests originating from a system principal are not +// accessible through WebRequest. text_ext_webrequest_background_events tests +// non-system principal requests. + +let testExtension = { + manifest: { + permissions: [ + "webRequest", + "<all_urls>", + ], + }, + background() { + let eventNames = [ + "onBeforeRequest", + "onBeforeSendHeaders", + "onSendHeaders", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + ]; + + function listener(name, details) { + // If we get anything, we failed. Removing the system principal check + // in ext-webrequest triggers this failure. + browser.test.fail(`received ${name}`); + } + + for (let name of eventNames) { + browser.webRequest[name].addListener( + listener.bind(null, name), + {urls: ["https://example.com/*"]} + ); + } + }, +}; + +add_task(async function test_webRequest_chromeworker_events() { + let extension = ExtensionTestUtils.loadExtension(testExtension); + await extension.startup(); + await new Promise(resolve => { + let worker = new ChromeWorker("webrequest_chromeworker.js"); + worker.onmessage = event => { + ok("chrome worker fetch finished"); + resolve(); + }; + worker.postMessage("go"); + }); + await extension.unload(); +}); + +add_task(async function test_webRequest_chromepage_events() { + let extension = ExtensionTestUtils.loadExtension(testExtension); + await extension.startup(); + await new Promise(resolve => { + fetch("https://example.com/example.txt").then(() => { + ok("test page loaded"); + resolve(); + }); + }); + await extension.unload(); +}); + +add_task(async function test_webRequest_jsm_events() { + let extension = ExtensionTestUtils.loadExtension(testExtension); + await extension.startup(); + await testFetch("https://example.com/example.txt").then(() => { + ok("fetch page loaded"); + }); + await testXHR("https://example.com/example.txt").then(() => { + ok("xhr page loaded"); + }); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html new file mode 100644 index 0000000000..19c812f59f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_host_permissions.html @@ -0,0 +1,89 @@ +<!doctype html> +<html> +<head> + <title>Test webRequest checks host permissions</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_webRequest_host_permissions() { + function background() { + function png(details) { + browser.test.sendMessage("png", details.url); + } + browser.webRequest.onBeforeRequest.addListener(png, {urls: ["*://*/*.png"]}); + browser.test.sendMessage("ready"); + } + + const all = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "<all_urls>"]}}); + const example = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "https://example.com/"]}}); + const mochi_test = ExtensionTestUtils.loadExtension({background, manifest: {permissions: ["webRequest", "http://mochi.test/"]}}); + + await all.startup(); + await example.startup(); + await mochi_test.startup(); + + await all.awaitMessage("ready"); + await example.awaitMessage("ready"); + await mochi_test.awaitMessage("ready"); + + const win1 = window.open("https://example.com/chrome/toolkit/components/extensions/test/mochitest/file_with_images.html"); + let urls = [await all.awaitMessage("png"), + await all.awaitMessage("png")]; + ok(urls.some(url => url.endsWith("good.png")), "<all_urls> permission gets to see good.png"); + ok((await example.awaitMessage("png")).endsWith("good.png"), "example permission sees same-origin example.com image"); + ok(urls.some(url => url.endsWith("great.png")), "<all_urls> permission also sees great.png"); + + // Clear the in-memory image cache, it can prevent listeners from receiving events. + const imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools); + imgTools.getImgCacheForDocument(win1.document).clearCache(false); + win1.close(); + + const win2 = window.open("http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_with_images.html"); + urls = [await all.awaitMessage("png"), + await all.awaitMessage("png")]; + ok(urls.some(url => url.endsWith("good.png")), "<all_urls> permission gets to see good.png"); + ok((await mochi_test.awaitMessage("png")).endsWith("great.png"), "mochi.test permission sees same-origin mochi.test image"); + ok(urls.some(url => url.endsWith("great.png")), "<all_urls> permission also sees great.png"); + win2.close(); + + await all.unload(); + await example.unload(); + await mochi_test.unload(); +}); + +add_task(async function test_webRequest_filter_permissions_warning() { + const manifest = { + permissions: ["webRequest", "http://example.com/"], + }; + + async function background() { + await browser.webRequest.onBeforeRequest.addListener(() => {}, {urls: ["http://example.org/"]}); + browser.test.notifyPass(); + } + + const extension = ExtensionTestUtils.loadExtension({manifest, background}); + + const warning = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [{message: /filter doesn't overlap with host permissions/}]); + }); + + await extension.startup(); + await extension.awaitFinish(); + + SimpleTest.endMonitorConsole(); + await warning; + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html new file mode 100644 index 0000000000..4c19359d8b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_webrequest_mozextension.html @@ -0,0 +1,193 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test moz-extension protocol use</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let peakAchu; +add_task(async function setup() { + peakAchu = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "<all_urls>", + ], + }, + background() { + // ID for the extension in the tests. Try to observe it to ensure we cannot. + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.notifyFail(`PeakAchu onBeforeRequest ${details.url}`); + }, {urls: ["<all_urls>", "moz-extension://*/*"]}); + + browser.test.onMessage.addListener((msg, extensionUrl) => { + browser.test.log(`spying for ${extensionUrl}`); + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.notifyFail(`PeakAchu onBeforeRequest ${details.url}`); + }, {urls: [extensionUrl]}); + }); + }, + }); + await peakAchu.startup(); +}); + +add_task(async function test_webRequest_no_mozextension_permission() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "tabs", + "moz-extension://c9e007e0-e518-ed4c-8202-83849981dd21/*", + "moz-extension://*/*", + ], + }, + background() { + browser.test.notifyPass("loaded"); + }, + }); + + let messages = [ + {message: /processing permissions\.2: Value "moz-extension:\/\/c9e007e0-e518-ed4c-8202-83849981dd21\/\*"/}, + {message: /processing permissions\.3: Value "moz-extension:\/\/\*\/\*"/}, + ]; + + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, messages); + }); + + await extension.startup(); + await extension.awaitFinish("loaded"); + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_webRequest_mozextension_fetch() { + function background() { + let page = browser.extension.getURL("fetched.html"); + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.assertEq(details.url, page, "got correct url in onBeforeRequest"); + browser.test.sendMessage("request-started"); + }, {urls: [browser.extension.getURL("*")]}, ["blocking"]); + browser.webRequest.onCompleted.addListener(details => { + browser.test.assertEq(details.url, page, "got correct url in onCompleted"); + browser.test.sendMessage("request-complete"); + }, {urls: [browser.extension.getURL("*")]}); + + browser.test.onMessage.addListener((msg, data) => { + fetch(page).then(() => { + browser.test.notifyPass("fetch success"); + browser.test.sendMessage("done"); + }, () => { + browser.test.fail("fetch failed"); + browser.test.sendMessage("done"); + }); + }); + browser.test.sendMessage("extensionUrl", browser.extension.getURL("*")); + } + + // Use webrequest to monitor moz-extension:// requests + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "tabs", + "<all_urls>", + ], + }, + files: { + "fetched.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>moz-extension file</h1> + </body> + </html> + `.trim(), + }, + background, + }); + + await extension.startup(); + // send the url for this extension to the monitoring extension + peakAchu.sendMessage("extensionUrl", await extension.awaitMessage("extensionUrl")); + + extension.sendMessage("testFetch"); + await extension.awaitMessage("request-started"); + await extension.awaitMessage("request-complete"); + await extension.awaitMessage("done"); + + await extension.unload(); +}); + +add_task(async function test_webRequest_mozextension_tab_query() { + function background() { + browser.test.sendMessage("extensionUrl", browser.extension.getURL("*")); + let page = browser.extension.getURL("tab.html"); + + async function onUpdated(tabId, tabInfo, tab) { + if (tabInfo.status !== "complete") { + return; + } + browser.test.log(`tab created ${tabId} ${JSON.stringify(tabInfo)} ${tab.url}`); + let tabs = await browser.tabs.query({url: browser.extension.getURL("*")}); + browser.test.assertEq(1, tabs.length, "got one tab"); + browser.test.assertEq(tabs.length && tabs[0].id, tab.id, "got the correct tab"); + browser.test.assertEq(tabs.length && tabs[0].url, page, "got correct url in tab"); + browser.tabs.remove(tabId); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.test.sendMessage("tabs-done"); + } + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.create({url: page}); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "tabs", + "<all_urls>", + ], + }, + files: { + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>moz-extension file</h1> + </body> + </html> + `.trim(), + }, + background, + }); + + await extension.startup(); + peakAchu.sendMessage("extensionUrl", await extension.awaitMessage("extensionUrl")); + await extension.awaitMessage("tabs-done"); + await extension.unload(); +}); + +add_task(async function teardown() { + await peakAchu.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html new file mode 100644 index 0000000000..78359747ce --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_chrome_native_messaging_paths.html @@ -0,0 +1,56 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script> + <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="chrome://mochikit/content/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const {OS} = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +// Test that the default paths searched for native host manifests +// are the ones we expect. +add_task(async function test_default_paths() { + let expectUser, expectGlobal; + switch (AppConstants.platform) { + case "macosx": { + expectUser = OS.Path.join(OS.Constants.Path.homeDir, + "Library/Application Support/Mozilla"); + expectGlobal = "/Library/Application Support/Mozilla"; + + break; + } + + case "linux": { + expectUser = OS.Path.join(OS.Constants.Path.homeDir, ".mozilla"); + + const libdir = AppConstants.HAVE_USR_LIB64_DIR ? "lib64" : "lib"; + expectGlobal = OS.Path.join("/usr", libdir, "mozilla"); + break; + } + + default: + // Fixed filesystem paths are only defined for MacOS and Linux, + // there's nothing to test on other platforms. + ok(false, `This test does not apply on ${AppConstants.platform}`); + break; + } + + let userDir = Services.dirsvc.get("XREUserNativeManifests", Ci.nsIFile).path; + is(userDir, expectUser, "user-specific native messaging directory is correct"); + + let globalDir = Services.dirsvc.get("XRESysNativeManifests", Ci.nsIFile).path; + is(globalDir, expectGlobal, "system-wide native messaing directory is correct"); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html new file mode 100644 index 0000000000..ce4689540d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_activityLog.html @@ -0,0 +1,390 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension activityLog test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_api() { + let URL = + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_sample.html"; + + // Test that an unspecified extension is not logged by the watcher extension. + let unlogged = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + applications: { gecko: { id: "unlogged@tests.mozilla.org" } }, + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + // This privileged test extension should not affect the webRequest + // data received by non-privileged extensions (See Bug 1576272). + browser.webRequest.onBeforeRequest.addListener( + details => { + return { cancel: false }; + }, + { urls: ["http://mochi.test/*/file_sample.html"] }, + ["blocking"] + ); + }, + }); + await unlogged.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "watched@tests.mozilla.org" } }, + permissions: [ + "tabs", + "tabHide", + "storage", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + content_scripts: [ + { + matches: ["http://mochi.test/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + files: { + "content_script.js": () => { + browser.test.sendMessage("content_script"); + }, + "registered_script.js": () => { + browser.test.sendMessage("registered_script"); + }, + }, + async background() { + let listen = () => {}; + async function runTest() { + // Test activity for a child function call. + browser.test.assertEq( + undefined, + browser.activityLog, + "activityLog requires permission" + ); + + // Test a child event manager. + browser.storage.onChanged.addListener(listen); + browser.storage.onChanged.removeListener(listen); + + // Test a parent event manager. + let webRequestListener = details => { + browser.webRequest.onBeforeRequest.removeListener(webRequestListener); + return { cancel: false }; + }; + browser.webRequest.onBeforeRequest.addListener( + webRequestListener, + { urls: ["http://mochi.test/*/file_sample.html"] }, + ["blocking"] + ); + + // A manifest based content script is already + // registered, we do a dynamic registration here. + await browser.contentScripts.register({ + js: [{ file: "registered_script.js" }], + matches: ["http://mochi.test/*/file_sample.html"], + runAt: "document_start", + }); + browser.test.sendMessage("ready"); + } + browser.test.onMessage.addListener((msg, data) => { + // Logging has started here so this listener is logged, but the + // call adding it was not. We do an additional onMessage.addListener + // call in the test function to validate child based event managers. + if (msg == "runtest") { + browser.test.assertTrue(true, msg); + runTest(); + } + if (msg == "hideTab") { + browser.tabs.hide(data); + } + }); + browser.test.sendMessage("url", browser.extension.getURL("")); + }, + }); + + async function backgroundScript(expectedUrl, extensionUrl) { + let expecting = [ + // Test child-only api_call. + { + type: "api_call", + name: "test.assertTrue", + data: { args: [true, "runtest"] }, + }, + + // Test child-only api_call. + { + type: "api_call", + name: "test.assertEq", + data: { + args: [undefined, undefined, "activityLog requires permission"], + }, + }, + // Test child addListener calls. + { + type: "api_call", + name: "storage.onChanged.addListener", + data: { + args: [], + }, + }, + { + type: "api_call", + name: "storage.onChanged.removeListener", + data: { + args: [], + }, + }, + // Test parent addListener calls. + { + type: "api_call", + name: "webRequest.onBeforeRequest.addListener", + data: { + args: [ + { + incognito: null, + tabId: null, + types: null, + urls: ["http://mochi.test/*/file_sample.html"], + windowId: null, + }, + ["blocking"], + ], + }, + }, + // Test an api that makes use of callParentAsyncFunction. + { + type: "api_call", + name: "contentScripts.register", + data: { + args: [ + { + allFrames: null, + css: null, + excludeGlobs: null, + excludeMatches: null, + includeGlobs: null, + js: [ + { + file: `${extensionUrl}registered_script.js`, + }, + ], + matchAboutBlank: null, + matches: ["http://mochi.test/*/file_sample.html"], + runAt: "document_start", + }, + ], + }, + }, + // Test child api_event calls. + { + type: "api_event", + name: "test.onMessage", + data: { args: ["runtest"] }, + }, + { + type: "api_call", + name: "test.sendMessage", + data: { args: ["ready"] }, + }, + // Test parent api_event calls. + { + type: "api_call", + name: "webRequest.onBeforeRequest.removeListener", + data: { + args: [], + }, + }, + { + type: "api_event", + name: "webRequest.onBeforeRequest", + data: { + args: [ + { + url: expectedUrl, + method: "GET", + type: "main_frame", + frameId: 0, + parentFrameId: -1, + incognito: false, + thirdParty: false, + ip: null, + frameAncestors: [], + urlClassification: { firstParty: [], thirdParty: [] }, + requestSize: 0, + responseSize: 0, + }, + ], + result: { + cancel: false, + }, + }, + }, + // Test manifest based content script. + { + type: "content_script", + name: "content_script.js", + data: { url: expectedUrl, tabId: 1 }, + }, + // registered script test + { + type: "content_script", + name: `${extensionUrl}registered_script.js`, + data: { url: expectedUrl, tabId: 1 }, + }, + { + type: "api_call", + name: "test.sendMessage", + data: { args: ["registered_script"], tabId: 1 }, + }, + { + type: "api_call", + name: "test.sendMessage", + data: { args: ["content_script"], tabId: 1 }, + }, + // Child api call + { + type: "api_call", + name: "tabs.hide", + data: { args: ["__TAB_ID"] }, + }, + { + type: "api_event", + name: "test.onMessage", + data: { args: ["hideTab", "__TAB_ID"] }, + }, + ]; + browser.test.assertTrue(browser.activityLog, "activityLog is privileged"); + + // Slightly less than a normal deep equal, we want to know that the values + // in our expected data are the same in the actual data, but we don't care + // if actual data has additional data or if data is in the same order in objects. + // This allows us to ignore keys that may be variable, or that are set in + // the api with an undefined value. + function deepEquivalent(a, b) { + if (a === b) { + return true; + } + if ( + typeof a != "object" || + typeof b != "object" || + a === null || + b === null + ) { + return false; + } + for (let k in a) { + if (!deepEquivalent(a[k], b[k])) { + return false; + } + } + return true; + } + + let tab; + let handler = async details => { + browser.test.log(`onExtensionActivity ${JSON.stringify(details)}`); + let test = expecting.shift(); + if (!test) { + browser.test.notifyFail(`no test for ${details.name}`); + } + + // On multiple runs, tabId will be different. Set the current + // tabId where we need it. + if (test.data.tabId !== undefined) { + test.data.tabId = tab.id; + } + if (test.data.args !== undefined) { + test.data.args = test.data.args.map(value => + value === "__TAB_ID" ? tab.id : value + ); + } + + browser.test.assertEq(test.type, details.type, "type matches"); + if (test.type == "content_script") { + browser.test.assertTrue( + details.name.includes(test.name), + "content script name matches" + ); + } else { + browser.test.assertEq(test.name, details.name, "name matches"); + } + + browser.test.assertTrue( + deepEquivalent(test.data, details.data), + `expected ${JSON.stringify( + test.data + )} included in actual ${JSON.stringify(details.data)}` + ); + if (!expecting.length) { + await browser.tabs.remove(tab.id); + browser.test.notifyPass("activity"); + } + }; + browser.activityLog.onExtensionActivity.addListener( + handler, + "watched@tests.mozilla.org" + ); + + browser.test.onMessage.addListener(async msg => { + if (msg === "opentab") { + tab = await browser.tabs.create({ url: expectedUrl }); + browser.test.sendMessage("tabid", tab.id); + } + if (msg === "done") { + browser.activityLog.onExtensionActivity.removeListener( + handler, + "watched@tests.mozilla.org" + ); + } + }); + } + + await extension.startup(); + let extensionUrl = await extension.awaitMessage("url"); + + let logger = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + applications: { gecko: { id: "watcher@tests.mozilla.org" } }, + permissions: ["activityLog"], + }, + background: `(${backgroundScript})("${URL}", "${extensionUrl}")`, + }); + await logger.startup(); + extension.sendMessage("runtest"); + await extension.awaitMessage("ready"); + logger.sendMessage("opentab"); + let id = await logger.awaitMessage("tabid"); + + await Promise.all([ + extension.awaitMessage("content_script"), + extension.awaitMessage("registered_script"), + ]); + + extension.sendMessage("hideTab", id); + await logger.awaitFinish("activity"); + + // Stop watching because we get extra calls on extension shutdown + // such as listener removal. + logger.sendMessage("done"); + + await extension.unload(); + await unlogged.unload(); + await logger.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js new file mode 100644 index 0000000000..62933bf008 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_all_apis.js @@ -0,0 +1,181 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Tests whether not too many APIs are visible by default. +// This file is used by test_ext_all_apis.html in browser/ and mobile/android/, +// which may modify the following variables to add or remove expected APIs. +/* globals expectedContentApisTargetSpecific */ +/* globals expectedBackgroundApisTargetSpecific */ + +// Generates a list of expectations. +function generateExpectations(list) { + return list + .reduce((allApis, path) => { + return allApis.concat(`browser.${path}`, `chrome.${path}`); + }, []) + .sort(); +} + +let expectedCommonApis = [ + "extension.getURL", + "extension.inIncognitoContext", + "extension.lastError", + "i18n.detectLanguage", + "i18n.getAcceptLanguages", + "i18n.getMessage", + "i18n.getUILanguage", + "runtime.OnInstalledReason", + "runtime.OnRestartRequiredReason", + "runtime.PlatformArch", + "runtime.PlatformOs", + "runtime.RequestUpdateCheckStatus", + "runtime.getManifest", + "runtime.connect", + "runtime.getURL", + "runtime.id", + "runtime.lastError", + "runtime.onConnect", + "runtime.onMessage", + "runtime.sendMessage", + // browser.test is only available in xpcshell or when + // Cu.isInAutomation is true. + "test.assertEq", + "test.assertFalse", + "test.assertRejects", + "test.assertThrows", + "test.assertTrue", + "test.fail", + "test.log", + "test.notifyFail", + "test.notifyPass", + "test.onMessage", + "test.sendMessage", + "test.succeed", + "test.withHandlingUserInput", +]; + +let expectedContentApis = [ + ...expectedCommonApis, + ...expectedContentApisTargetSpecific, +]; + +let expectedBackgroundApis = [ + ...expectedCommonApis, + ...expectedBackgroundApisTargetSpecific, + "contentScripts.register", + "experiments.APIChildScope", + "experiments.APIEvent", + "experiments.APIParentScope", + "extension.ViewType", + "extension.getBackgroundPage", + "extension.getViews", + "extension.isAllowedFileSchemeAccess", + "extension.isAllowedIncognitoAccess", + // Note: extensionTypes is not visible in Chrome. + "extensionTypes.CSSOrigin", + "extensionTypes.ImageFormat", + "extensionTypes.RunAt", + "management.ExtensionDisabledReason", + "management.ExtensionInstallType", + "management.ExtensionType", + "management.getSelf", + "management.uninstallSelf", + "permissions.getAll", + "permissions.contains", + "permissions.request", + "permissions.remove", + "permissions.onAdded", + "permissions.onRemoved", + "runtime.getBackgroundPage", + "runtime.getBrowserInfo", + "runtime.getPlatformInfo", + "runtime.onConnectExternal", + "runtime.onInstalled", + "runtime.onMessageExternal", + "runtime.onStartup", + "runtime.onUpdateAvailable", + "runtime.openOptionsPage", + "runtime.reload", + "runtime.setUninstallURL", + "theme.getCurrent", + "theme.onUpdated", + "types.LevelOfControl", + "types.SettingScope", +]; + +function sendAllApis() { + function isEvent(key, val) { + if (!/^on[A-Z]/.test(key)) { + return false; + } + let eventKeys = []; + for (let prop in val) { + eventKeys.push(prop); + } + eventKeys = eventKeys.sort().join(); + return eventKeys === "addListener,hasListener,removeListener"; + } + function mayRecurse(key, val) { + if (Object.keys(val).filter(k => !/^[A-Z\-0-9_]+$/.test(k)).length === 0) { + // Don't recurse on constants and empty objects. + return false; + } + return !isEvent(key, val); + } + + let results = []; + function diveDeeper(path, obj) { + for (let key in obj) { + let val = obj[key]; + if (typeof val == "object" && val !== null && mayRecurse(key, val)) { + diveDeeper(`${path}.${key}`, val); + } else if (val !== undefined) { + results.push(`${path}.${key}`); + } + } + } + diveDeeper("browser", browser); + diveDeeper("chrome", chrome); + browser.test.sendMessage("allApis", results.sort()); +} + +add_task(async function test_enumerate_content_script_apis() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/*/file_sample.html"], + js: ["contentscript.js"], + run_at: "document_start", + }, + ], + }, + files: { + "contentscript.js": sendAllApis, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + let actualApis = await extension.awaitMessage("allApis"); + win.close(); + let expectedApis = generateExpectations(expectedContentApis); + isDeeply(actualApis, expectedApis, "content script APIs"); + + await extension.unload(); +}); + +add_task(async function test_enumerate_background_script_apis() { + let extensionData = { + background: sendAllApis, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let actualApis = await extension.awaitMessage("allApis"); + let expectedApis = generateExpectations(expectedBackgroundApis); + isDeeply(actualApis, expectedApis, "background script APIs"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html new file mode 100644 index 0000000000..ffa421e042 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_async_clipboard.html @@ -0,0 +1,376 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Async Clipboard permissions tests</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; + +// Bug 1479956 - On android-debug verify this test times out +SimpleTest.requestLongerTimeout(2); + +/* globals clipboardWriteText, clipboardWrite, clipboardReadText, clipboardRead */ +function shared() { + this.clipboardWriteText = function(txt) { + return navigator.clipboard.writeText(txt); + }; + + this.clipboardWrite = function(dt) { + return navigator.clipboard.write(dt); + }; + + this.clipboardReadText = function() { + return navigator.clipboard.readText(); + }; + + this.clipboardRead = function() { + return navigator.clipboard.read(); + }; +} + +/** + * Clear the clipboard. + * + * This is needed because Services.clipboard.emptyClipboard() does not clear the actual system clipboard. + */ +function clearClipboard() { + if (AppConstants.platform == "android") { + // On android, this clears the actual system clipboard + SpecialPowers.Services.clipboard.emptyClipboard(SpecialPowers.Services.clipboard.kGlobalClipboard); + return; + } + // Need to do this hack on other platforms to clear the actual system clipboard + let transf = SpecialPowers.Cc["@mozilla.org/widget/transferable;1"] + .createInstance(SpecialPowers.Ci.nsITransferable); + transf.init(null); + // Empty transferables may cause crashes, so just add an unknown type. + const TYPE = "text/x-moz-place-empty"; + transf.addDataFlavor(TYPE); + transf.setTransferData(TYPE, {}, 0); + SpecialPowers.Services.clipboard.setData(transf, null, SpecialPowers.Services.clipboard.kGlobalClipboard); +} + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({"set": [ + ["dom.events.asyncClipboard", true], + ["dom.events.asyncClipboard.dataTransfer", true], + ]}); +}); + +// Test that without enough permissions, we are NOT allowed to use writeText, write, read or readText in background script +add_task(async function test_background_async_clipboard_no_permissions() { + function backgroundScript() { + let dt = new DataTransfer(); + dt.items.add("Howdy", "text/plain"); + browser.test.assertRejects(clipboardRead(), undefined, "Read should be denied without permission"); + browser.test.assertRejects(clipboardWrite(dt), undefined, "Write should be denied without permission"); + browser.test.assertRejects(clipboardWriteText("blabla"), undefined, "WriteText should be denied without permission"); + browser.test.assertRejects(clipboardReadText(), undefined, "ReadText should be denied without permission"); + browser.test.sendMessage("ready"); + } + let extensionData = { + background: [shared, backgroundScript], + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + await extension.unload(); +}); + +// Test that without enough permissions, we are NOT allowed to use writeText, write, read or readText in content script +add_task(async function test_contentscript_async_clipboard_no_permission() { + function contentScript() { + let dt = new DataTransfer(); + dt.items.add("Howdy", "text/plain"); + browser.test.assertRejects(clipboardRead(), undefined, "Read should be denied without permission"); + browser.test.assertRejects(clipboardWrite(dt), undefined, "Write should be denied without permission"); + browser.test.assertRejects(clipboardWriteText("blabla"), undefined, "WriteText should be denied without permission"); + browser.test.assertRejects(clipboardReadText(), undefined, "ReadText should be denied without permission"); + browser.test.sendMessage("ready"); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); + +// Test that with enough permissions, we are allowed to use writeText in content script +add_task(async function test_contentscript_clipboard_permission_writetext() { + function contentScript() { + let str = "HI"; + clipboardWriteText(str).then(function() { + // nothing here + browser.test.sendMessage("ready"); + }, function(err) { + browser.test.fail("WriteText promise rejected"); + browser.test.sendMessage("ready"); + }); // clipboardWriteText + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + const actual = SpecialPowers.getClipboardData("text/unicode"); + is(actual, "HI", "right string copied by write"); + win.close(); + await extension.unload(); +}); + +// Test that with enough permissions, we are allowed to use readText in content script +add_task(async function test_contentscript_clipboard_permission_readtext() { + function contentScript() { + let str = "HI"; + clipboardReadText().then(function(strData) { + if (strData == str) { + browser.test.succeed("Successfully read from clipboard"); + } else { + browser.test.fail("ReadText read the wrong thing from clipboard:" + strData); + } + browser.test.sendMessage("ready"); + }, function(err) { + browser.test.fail("ReadText promise rejected"); + browser.test.sendMessage("ready"); + }); // clipboardReadText + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + await SimpleTest.promiseClipboardChange("HI", () => { + SpecialPowers.clipboardCopyString("HI"); + }, "text/unicode"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); + +// Test that with enough permissions, we are allowed to use write in content script +add_task(async function test_contentscript_clipboard_permission_write() { + function contentScript() { + let str = "HI"; + let dt = new DataTransfer(); + dt.items.add(str, "text/plain"); + clipboardWrite(dt).then(function() { + // nothing here + browser.test.sendMessage("ready"); + }, function(err) { // clipboardWrite promise error function + browser.test.fail("Write promise rejected"); + browser.test.sendMessage("ready"); + }); // clipboard write + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + const actual = SpecialPowers.getClipboardData("text/unicode"); + is(actual, "HI", "right string copied by write"); + win.close(); + await extension.unload(); +}); + +// Test that with enough permissions, we are allowed to use read in content script +add_task(async function test_contentscript_clipboard_permission_read() { + function contentScript() { + clipboardRead().then(function(dt) { + let s = dt.getData("text/plain"); + if (s == "HELLO") { + browser.test.succeed("Read promise successfully read the right thing"); + } else { + browser.test.fail("Read read the wrong string from clipboard:" + s); + } + browser.test.sendMessage("ready"); + }, function(err) { // clipboardRead promise error function + browser.test.fail("Read promise rejected"); + browser.test.sendMessage("ready"); + }); // clipboard read + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + await SimpleTest.promiseClipboardChange("HELLO", () => { + SpecialPowers.clipboardCopyString("HELLO"); + }, "text/unicode"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); + +// Test that performing readText(...) when the clipboard is empty returns an empty string +add_task(async function test_contentscript_clipboard_nocontents_readtext() { + function contentScript() { + clipboardReadText().then(function(strData) { + if (strData == "") { + browser.test.succeed("ReadText successfully read correct thing from an empty clipboard"); + } else { + browser.test.fail("ReadText should have read an empty string, but read:" + strData); + } + browser.test.sendMessage("ready"); + }, function(err) { + browser.test.fail("ReadText promise rejected: " + err); + browser.test.sendMessage("ready"); + }); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + + await SimpleTest.promiseClipboardChange("", () => { + clearClipboard(); + }, "text/x-moz-place-empty"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); + +// Test that performing read(...) when the clipboard is empty returns an empty data transfer +add_task(async function test_contentscript_clipboard_nocontents_read() { + function contentScript() { + clipboardRead().then(function(dataT) { + // On macOS if we clear the clipboard and read from it, there will be + // no items in the data transfer object. + // On linux with e10s enabled clearing of the clipboard does not happen in + // the same way as it does on other platforms. So when we clear the clipboard + // and read from it, the data transfer object contains an item of type + // text/plain and kind string, but we can't call getAsString on it to verify + // that at least it is an empty string because the callback never gets invoked. + if (!dataT.items.length || + (dataT.items.length == 1 && dataT.items[0].type == "text/plain" && + dataT.items[0].kind == "string")) { + browser.test.succeed("Read promise successfully resolved"); + } else { + browser.test.fail("Read read the wrong thing from clipboard, " + + "data transfer has this many items:" + dataT.items.length); + } + browser.test.sendMessage("ready"); + }, function(err) { + browser.test.fail("Read promise rejected: " + err); + browser.test.sendMessage("ready"); + }); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["https://example.com/*/file_sample.html"], + }], + permissions: [ + "clipboardRead", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + + await SimpleTest.promiseClipboardChange("", () => { + clearClipboard(); + }, "text/x-moz-place-empty"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html new file mode 100644 index 0000000000..8b6fba25bb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_canvas.html @@ -0,0 +1,50 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for background page canvas rendering</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_background_canvas() { + function background() { + try { + let canvas = document.createElement("canvas"); + + let context = canvas.getContext("2d"); + + // This ensures that we have a working PresShell, and can successfully + // calculate font metrics. + context.font = "8pt fixed"; + + browser.test.notifyPass("background-canvas"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("background-canvas"); + } + } + + let extensionData = { + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "background_canvas@tests.mozilla.org" } }, + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.awaitFinish("background-canvas"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_background_page.html b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html new file mode 100644 index 0000000000..9cafd8a61a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_background_page.html @@ -0,0 +1,84 @@ +<!DOCTYPE HTML> +<html> + <head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js" type="text/javascript"></script> + <link href="/tests/SimpleTest/test.css" rel="stylesheet"/> + </head> + <body> + + <script type="text/javascript"> + "use strict"; + + /* eslint-disable mozilla/balanced-listeners */ + + add_task(async function testAlertNotShownInBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background: function () { + alert("I am an alert in the background."); + + browser.test.notifyPass("alertCalled"); + } + }); + + let consoleOpened = loadChromeScript(() => { + const {sendAsyncMessage, assert} = this; + assert.ok(!Services.wm.getEnumerator("alert:alert").hasMoreElements(), "Alerts should not be present at the start of the test."); + + Services.obs.addObserver(function observer() { + sendAsyncMessage("web-console-created"); + Services.obs.removeObserver(observer, "web-console-created"); + }, "web-console-created"); + }); + let opened = consoleOpened.promiseOneMessage("web-console-created"); + + consoleMonitor.start([ + { + message: /alert\(\) is not supported in background windows/ + }, { + message: /I am an alert in the background/ + } + ]); + + await extension.startup(); + await extension.awaitFinish("alertCalled"); + + let chromeScript = loadChromeScript(async () => { + const {assert} = this; + assert.ok(!Services.wm.getEnumerator("alert:alert").hasMoreElements(), "Alerts should not be present after calling alert()."); + }); + chromeScript.destroy(); + + await consoleMonitor.finished(); + + await opened; + consoleOpened.destroy(); + + chromeScript = loadChromeScript(async () => { + const {sendAsyncMessage} = this; + let {require} = ChromeUtils.import ("resource://devtools/shared/Loader.jsm"); + require("devtools/client/framework/devtools-browser"); + let {BrowserConsoleManager} = require("devtools/client/webconsole/browser-console-manager"); + + // And then double check that we have an actual browser console. + let haveConsole = !!BrowserConsoleManager.getBrowserConsole(); + + if (haveConsole) { + await BrowserConsoleManager.toggleBrowserConsole(); + } + sendAsyncMessage("done", haveConsole); + }); + + let consoleShown = await chromeScript.promiseOneMessage("done"); + ok(consoleShown, "console was shown"); + chromeScript.destroy(); + + await extension.unload(); + }); + </script> + + </body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html new file mode 100644 index 0000000000..f7d36633db --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_indexedDB.html @@ -0,0 +1,161 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.remove indexedDB</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testIndexedDB() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + async function background() { + const PAGE = + "/tests/toolkit/components/extensions/test/mochitest/file_indexedDB.html"; + + let tabs = []; + + browser.test.onMessage.addListener(async msg => { + if (msg == "cleanup") { + await Promise.all(tabs.map(tabId => browser.tabs.remove(tabId))); + browser.test.sendMessage("done"); + return; + } + + await browser.browsingData.remove(msg, { indexedDB: true }); + browser.test.sendMessage("indexedDBRemoved"); + }); + + // Create two tabs. + let tab = await browser.tabs.create({ url: `http://mochi.test:8888${PAGE}` }); + tabs.push(tab.id); + + tab = await browser.tabs.create({ url: `http://example.com${PAGE}` }); + tabs.push(tab.id); + + // Create tab with cookieStoreId "firefox-container-1" + tab = await browser.tabs.create({ url: `http://example.net${PAGE}`, cookieStoreId: 'firefox-container-1' }); + tabs.push(tab.id); + } + + function contentScript() { + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener( + "message", + msg => { + browser.test.sendMessage("indexedDBCreated"); + }, + true + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background, + manifest: { + applications: { gecko: { id: "indexedDb@tests.mozilla.org" } }, + permissions: ["browsingData", "tabs", "cookies"], + content_scripts: [ + { + matches: [ + "http://mochi.test/*/file_indexedDB.html", + "http://example.com/*/file_indexedDB.html", + "http://example.net/*/file_indexedDB.html", + ], + js: ["script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "script.js": contentScript, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("indexedDBCreated"); + await extension.awaitMessage("indexedDBCreated"); + await extension.awaitMessage("indexedDBCreated"); + + function getUsage() { + return new Promise(resolve => { + let qms = SpecialPowers.Services.qms; + let cb = SpecialPowers.wrapCallback(request => resolve(request.result)); + qms.getUsage(cb); + }); + } + + async function getOrigins() { + let origins = []; + let result = await getUsage(); + for (let i = 0; i < result.length; ++i) { + if (result[i].usage === 0) { + continue; + } + if ( + result[i].origin.startsWith("http://mochi.test") || + result[i].origin.startsWith("http://example.com") || + result[i].origin.startsWith("http://example.net") + ) { + origins.push(result[i].origin); + } + } + return origins.sort(); + } + + let origins = await getOrigins(); + is(origins.length, 3, "IndexedDB databases have been populated."); + + // Deleting private browsing mode data is silently ignored. + extension.sendMessage({ cookieStoreId: "firefox-private" }); + await extension.awaitMessage("indexedDBRemoved"); + + origins = await getOrigins(); + is(origins.length, 3, "All indexedDB remains after clearing firefox-private"); + + // Delete by hostname + extension.sendMessage({ hostnames: ["example.com"] }); + await extension.awaitMessage("indexedDBRemoved"); + + origins = await getOrigins(); + is(origins.length, 2, "IndexedDB data only for only two domains left"); + ok(origins[0].startsWith("http://example.net"), "example.net not deleted"); + ok(origins[1].startsWith("http://mochi.test"), "mochi.test not deleted"); + + // TODO: Bug 1643740 + if (AppConstants.platform != "android") { + // Delete by cookieStoreId + extension.sendMessage({ cookieStoreId: "firefox-container-1" }); + await extension.awaitMessage("indexedDBRemoved"); + + origins = await getOrigins(); + is(origins.length, 1, "IndexedDB data only for only one domain"); + ok(origins[0].startsWith("http://mochi.test"), "mochi.test not deleted"); + } + + // Delete all + extension.sendMessage({}); + await extension.awaitMessage("indexedDBRemoved"); + + origins = await getOrigins(); + is(origins.length, 0, "All IndexedDB data has been removed."); + + await extension.sendMessage("cleanup"); + await extension.awaitMessage("done"); + + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html new file mode 100644 index 0000000000..cf6c420366 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_localStorage.html @@ -0,0 +1,322 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.remove indexedDB</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + // make sure userContext is enabled. + return SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); +}); + +add_task(async function testLocalStorage() { + async function background() { + function waitForTabs() { + return new Promise(resolve => { + let tabs = {}; + + let listener = async (msg, { tab }) => { + if (msg !== "content-script-ready") { + return; + } + + tabs[tab.url] = tab; + if (Object.keys(tabs).length == 3) { + browser.runtime.onMessage.removeListener(listener); + resolve(tabs); + } + }; + browser.runtime.onMessage.addListener(listener); + }); + } + + function sendMessageToTabs(tabs, message) { + return Promise.all( + Object.values(tabs).map(tab => { + return browser.tabs.sendMessage(tab.id, message); + }) + ); + } + + let tabs = await waitForTabs(); + + browser.test.assertRejects( + browser.browsingData.removeLocalStorage({ since: Date.now() }), + "Firefox does not support clearing localStorage with 'since'.", + "Expected error received when using unimplemented parameter 'since'." + ); + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await browser.browsingData.removeLocalStorage({ + hostnames: ["example.com"], + }); + await browser.tabs.sendMessage(tabs["http://example.com/"].id, "checkLocalStorageCleared"); + await browser.tabs.sendMessage(tabs["http://example.net/"].id, "checkLocalStorageSet"); + + if ( + SpecialPowers.Services.domStorageManager.nextGenLocalStorageEnabled === + false + ) { + // This assertion fails when localStorage is using the legacy + // implementation (See Bug 1595431). + browser.test.log("Skipped assertion on nextGenLocalStorageEnabled=false"); + } else { + await browser.tabs.sendMessage(tabs["http://test1.example.com/"].id, "checkLocalStorageSet"); + } + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + await browser.browsingData.removeLocalStorage({}); + await sendMessageToTabs(tabs, "checkLocalStorageCleared"); + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + await browser.browsingData.remove({}, { localStorage: true }); + await sendMessageToTabs(tabs, "checkLocalStorageCleared"); + + // Can only delete cookieStoreId with LSNG enabled. + if (SpecialPowers.Services.domStorageManager.nextGenLocalStorageEnabled) { + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + await browser.browsingData.removeLocalStorage({ + cookieStoreId: "firefox-container-1", + }); + await browser.tabs.sendMessage(tabs["http://example.com/"].id, "checkLocalStorageSet"); + await browser.tabs.sendMessage(tabs["http://example.net/"].id, "checkLocalStorageSet"); + + // TODO: containers support is lacking on GeckoView (Bug 1643740) + if (!navigator.userAgent.includes("Android")) { + await browser.tabs.sendMessage(tabs["http://test1.example.com/"].id, "checkLocalStorageCleared"); + } + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + // Hostname doesn't match, so nothing cleared. + await browser.browsingData.removeLocalStorage({ + cookieStoreId: "firefox-container-1", + hostnames: ["example.net"], + }); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + + await sendMessageToTabs(tabs, "resetLocalStorage"); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + // Deleting private browsing mode data is silently ignored. + await browser.browsingData.removeLocalStorage({ + cookieStoreId: "firefox-private", + }); + await sendMessageToTabs(tabs, "checkLocalStorageSet"); + } else { + await browser.test.assertRejects( + browser.browsingData.removeLocalStorage({ + cookieStoreId: "firefox-container-1", + }), + "removeLocalStorage with cookieStoreId requires LSNG" + ); + } + + // Cleanup (checkLocalStorageCleared creates empty LS databases). + await browser.browsingData.removeLocalStorage({}); + + browser.test.notifyPass("done"); + } + + function contentScript() { + browser.runtime.onMessage.addListener(msg => { + if (msg === "resetLocalStorage") { + localStorage.clear(); + localStorage.setItem("test", "test"); + } else if (msg === "checkLocalStorageSet") { + browser.test.assertEq( + "test", + localStorage.getItem("test"), + `checkLocalStorageSet: ${location.href}` + ); + } else if (msg === "checkLocalStorageCleared") { + browser.test.assertEq( + null, + localStorage.getItem("test"), + `checkLocalStorageCleared: ${location.href}` + ); + } + }); + browser.runtime.sendMessage("content-script-ready"); + } + + // This extension is responsible for opening tabs with a specified + // cookieStoreId, we use a separate extension to make sure that browsingData + // works without the cookies permission. + let openTabsExtension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + name: "Open tabs", + applications: { gecko: { id: "open-tabs@tests.mozilla.org" }, }, + permissions: ["cookies"], + }, + async background() { + const TABS = [ + { url: "http://example.com" }, + { url: "http://example.net" }, + { + url: "http://test1.example.com", + cookieStoreId: 'firefox-container-1', + }, + ]; + + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId_, changed, tab) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + let tabs = []; + let loaded = []; + for (let options of TABS) { + let tab = await browser.tabs.create(options); + loaded.push(awaitLoad(tab.id)); + tabs.push(tab); + } + + await Promise.all(loaded); + + browser.test.onMessage.addListener(async msg => { + if (msg === "cleanup") { + const tabIds = tabs.map(tab => tab.id); + let removedTabs = 0; + browser.tabs.onRemoved.addListener(tabId => { + browser.test.log(`Removing tab ${tabId}.`); + if (tabIds.includes(tabId)) { + removedTabs++; + if (removedTabs == tabIds.length) { + browser.test.sendMessage("done"); + } + } + }); + await browser.tabs.remove(tabIds); + } + }); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background, + manifest: { + name: "Test Extension", + applications: { gecko: { id: "localStorage@tests.mozilla.org" } }, + permissions: ["browsingData", "tabs"], + content_scripts: [ + { + matches: [ + "http://example.com/", + "http://example.net/", + "http://test1.example.com/", + ], + js: ["content-script.js"], + run_at: "document_end", + }, + ], + }, + files: { + "content-script.js": contentScript, + }, + }); + + await openTabsExtension.startup(); + + await extension.startup(); + await extension.awaitFinish("done"); + await extension.unload(); + + await openTabsExtension.sendMessage("cleanup"); + await openTabsExtension.awaitMessage("done"); + await openTabsExtension.unload(); +}); + +// Verify that browsingData.removeLocalStorage doesn't break on data stored +// in about:newtab or file principals. +add_task(async function test_browserData_on_aboutnewtab_and_file_data() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + async background() { + await browser.browsingData.removeLocalStorage({}).catch(err => { + browser.test.fail(`${err} :: ${err.stack}`); + }); + browser.test.sendMessage("done"); + }, + manifest: { + applications: { gecko: { id: "indexed-db-file@test.mozilla.org" } }, + permissions: ["browsingData"], + }, + }); + + await new Promise(resolve => { + const chromeScript = SpecialPowers.loadChromeScript(async () => { + const { SiteDataTestUtils } = ChromeUtils.import( + "resource://testing-common/SiteDataTestUtils.jsm" + ); + await SiteDataTestUtils.addToIndexedDB("about:newtab"); + await SiteDataTestUtils.addToIndexedDB("file:///fake/file"); + // eslint-disable-next-line no-undef + sendAsyncMessage("done"); + }); + + chromeScript.addMessageListener("done", () => { + chromeScript.destroy(); + resolve(); + }); + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_browserData_should_not_remove_extension_data() { + if (!SpecialPowers.getBoolPref("dom.storage.next_gen")) { + // When LSNG isn't enabled, the browsingData API does still clear + // all the extensions localStorage if called without a list of specific + // origins to clear. + info("Test skipped because LSNG is currently disabled"); + return; + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + async background() { + window.localStorage.setItem("key", "value"); + await browser.browsingData.removeLocalStorage({}).catch(err => { + browser.test.fail(`${err} :: ${err.stack}`); + }); + browser.test.sendMessage("done", window.localStorage.getItem("key")); + }, + manifest: { + applications: { gecko: { id: "extension-data@tests.mozilla.org" } }, + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + const lsValue = await extension.awaitMessage("done"); + is(lsValue, "value", "Got the expected localStorage data"); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html new file mode 100644 index 0000000000..ff75ca7b9f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_pluginData.html @@ -0,0 +1,71 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.remove indexedDB</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// NB: Since plugins are disabled, there is never any data to clear. +// We are really testing that these operations are no-ops. + +add_task(async function testPluginData() { + async function background() { + const REFERENCE_DATE = Date.now(); + const TEST_CASES = [ + // Clear plugin data with no since value. + {}, + // Clear pluginData with recent since value. + { since: REFERENCE_DATE - 20000 }, + // Clear pluginData with old since value. + { since: REFERENCE_DATE - 1000000 }, + // Clear pluginData for specific hosts. + { hostnames: ["bar.com", "baz.com"] }, + // Clear pluginData for no hosts. + { hostnames: [] }, + ]; + + for (let method of ["removePluginData", "remove"]) { + for (let options of TEST_CASES) { + browser.test.log(`Testing ${method} with ${JSON.stringify(options)}`); + if (method == "removePluginData") { + await browser.browsingData.removePluginData(options); + } else { + await browser.browsingData.remove(options, { pluginData: true }); + } + } + } + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background, + manifest: { + applications: { gecko: { id: "remove-plugin@tests.mozilla.org" } }, + permissions: ["tabs", "browsingData"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + + // This test has no assertions because it's only meant to check that we don't + // throw when calling removePluginData and remove with pluginData: true. + ok(true, "dummy check"); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html new file mode 100644 index 0000000000..a97a62a0f4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_serviceWorkers.html @@ -0,0 +1,141 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.remove indexedDB</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const { TestUtils } = SpecialPowers.Cu.import("resource://testing-common/TestUtils.jsm"); + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.serviceWorkers.exemptFromPerDomainMax", true], + ["dom.serviceWorkers.enabled", true], + ["dom.serviceWorkers.testing.enabled", true], + ], + }); +}); + +add_task(async function testServiceWorkers() { + async function background() { + const PAGE = + "/tests/toolkit/components/extensions/test/mochitest/file_serviceWorker.html"; + + browser.runtime.onMessage.addListener(msg => { + browser.test.sendMessage("serviceWorkerRegistered"); + }); + + let tabs = []; + + browser.test.onMessage.addListener(async msg => { + if (msg == "cleanup") { + await browser.tabs.remove(tabs.map(tab => tab.id)); + browser.test.sendMessage("done"); + return; + } + + await browser.browsingData.remove( + { hostnames: msg.hostnames }, + { serviceWorkers: true } + ); + browser.test.sendMessage("serviceWorkersRemoved"); + }); + + // Create two serviceWorkers. + let tab = await browser.tabs.create({ url: `http://mochi.test:8888${PAGE}` }); + tabs.push(tab); + + tab = await browser.tabs.create({ url: `http://example.com${PAGE}` }); + tabs.push(tab); + } + + function contentScript() { + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener( + "message", + msg => { + if (msg.data == "serviceWorkerRegistered") { + browser.runtime.sendMessage("serviceWorkerRegistered"); + } + }, + true + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background, + manifest: { + applications: { gecko: { id: "service-workers@tests.mozilla.org" } }, + permissions: ["browsingData", "tabs"], + content_scripts: [ + { + matches: [ + "http://mochi.test/*/file_serviceWorker.html", + "http://example.com/*/file_serviceWorker.html", + ], + js: ["script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "script.js": contentScript, + }, + }); + + await extension.startup(); + await extension.awaitMessage("serviceWorkerRegistered"); + await extension.awaitMessage("serviceWorkerRegistered"); + + // Even though we await the registrations by waiting for the messages, + // sometimes the serviceWorkers are still not registered at this point. + async function getRegistrations(count) { + await TestUtils.waitForCondition( + async () => (await SpecialPowers.registeredServiceWorkers()).length === count, + `Wait for ${count} service workers to be registered` + ); + return SpecialPowers.registeredServiceWorkers(); + } + + let serviceWorkers = await getRegistrations(2); + is(serviceWorkers.length, 2, "ServiceWorkers have been registered."); + + extension.sendMessage({ hostnames: ["example.com"] }); + await extension.awaitMessage("serviceWorkersRemoved"); + + serviceWorkers = await getRegistrations(1); + is( + serviceWorkers.length, + 1, + "ServiceWorkers for example.com have been removed." + ); + + let { scriptSpec } = serviceWorkers[0]; + dump(`Service worker spec: ${scriptSpec}`); + ok(scriptSpec.startsWith("http://mochi.test:8888/"), + "ServiceWorkers for example.com have been removed."); + + extension.sendMessage({}); + await extension.awaitMessage("serviceWorkersRemoved"); + + serviceWorkers = await getRegistrations(0); + is(serviceWorkers.length, 0, "All ServiceWorkers have been removed."); + + extension.sendMessage("cleanup"); + await extension.awaitMessage("done"); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html new file mode 100644 index 0000000000..3b1d5e1af9 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_browsingData_settings.html @@ -0,0 +1,67 @@ +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> +<!DOCTYPE HTML> +<html> +<head> + <title>Test browsingData.settings</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const SETTINGS_LIST = [ + "cache", + "cookies", + "history", + "formData", + "downloads", +].sort(); + +add_task(async function testSettings() { + async function background() { + browser.browsingData.settings().then(settings => { + browser.test.sendMessage("settings", settings); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background, + manifest: { + applications: { gecko: { id: "browsingData-settings@tests.mozilla.org" } }, + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + let settings = await extension.awaitMessage("settings"); + + // Verify that we get the keys back we expect. + isDeeply( + Object.entries(settings.dataToRemove) + .filter(([key, value]) => value) + .map(([key, value]) => key) + .sort(), + SETTINGS_LIST, + "dataToRemove contains expected properties." + ); + isDeeply( + Object.entries(settings.dataRemovalPermitted) + .filter(([key, value]) => value) + .map(([key, value]) => key) + .sort(), + SETTINGS_LIST, + "dataToRemove contains expected properties." + ); + is("since" in settings.options, true, "options contains |since|"); + + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html new file mode 100644 index 0000000000..7116d03235 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_canvas_resistFingerprinting.html @@ -0,0 +1,64 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_cookies.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.resistFingerprinting", true]], + }); +}); + +add_task(async function test_contentscript() { + function contentScript() { + let canvas = document.createElement("canvas"); + canvas.width = canvas.height = "100"; + + let ctx = canvas.getContext("2d"); + ctx.fillStyle = "green"; + ctx.fillRect(0, 0, 100, 100); + let data = ctx.getImageData(0, 0, 100, 100); + + browser.test.sendMessage("data-color", data.data[1]); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }; + const url = "http://mochi.test:8888/chrome/toolkit/components/extensions/test/mochitest/file_sample.html"; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let win = window.open(url); + let color = await extension.awaitMessage("data-color"); + is(color, 128, "Got correct pixel data for green"); + win.close(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html new file mode 100644 index 0000000000..77ac767391 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard.html @@ -0,0 +1,210 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Clipboard permissions tests</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; + +/* globals doCopy, doPaste */ +function shared() { + let field = document.createElement("textarea"); + document.body.appendChild(field); + field.contentEditable = true; + + this.doCopy = function(txt) { + field.value = txt; + field.select(); + return document.execCommand("copy"); + }; + + this.doPaste = function() { + field.select(); + return document.execCommand("paste") && field.value; + }; +} + +add_task(async function test_background_clipboard_permissions() { + function backgroundScript() { + browser.test.assertEq(false, doCopy("whatever"), + "copy should be denied without permission"); + browser.test.assertEq(false, doPaste(), + "paste should be denied without permission"); + browser.test.sendMessage("ready"); + } + let extensionData = { + background: [shared, backgroundScript], + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("ready"); + + await extension.unload(); +}); + +add_task(async function test_background_clipboard_copy() { + function backgroundScript() { + browser.test.onMessage.addListener(txt => { + browser.test.assertEq(true, doCopy(txt), + "copy should be allowed with permission"); + }); + browser.test.sendMessage("ready"); + } + let extensionData = { + background: `(${shared})();(${backgroundScript})();`, + manifest: { + permissions: [ + "clipboardWrite", + ], + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + const DUMMY_STR = "dummy string to copy"; + await new Promise(resolve => { + SimpleTest.waitForClipboard(DUMMY_STR, () => { + extension.sendMessage(DUMMY_STR); + }, resolve, resolve); + }); + + await extension.unload(); +}); + +add_task(async function test_contentscript_clipboard_permissions() { + function contentScript() { + browser.test.assertEq(false, doCopy("whatever"), + "copy should be denied without permission"); + browser.test.assertEq(false, doPaste(), + "paste should be denied without permission"); + browser.test.sendMessage("ready"); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + await extension.awaitMessage("ready"); + win.close(); + + await extension.unload(); +}); + +add_task(async function test_contentscript_clipboard_copy() { + function contentScript() { + browser.test.onMessage.addListener(txt => { + browser.test.assertEq(true, doCopy(txt), + "copy should be allowed with permission"); + }); + browser.test.sendMessage("ready"); + } + let extensionData = { + manifest: { + content_scripts: [{ + js: ["shared.js", "contentscript.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + permissions: [ + "clipboardWrite", + ], + }, + files: { + "shared.js": shared, + "contentscript.js": contentScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + await extension.awaitMessage("ready"); + + const DUMMY_STR = "dummy string to copy in content script"; + await new Promise(resolve => { + SimpleTest.waitForClipboard(DUMMY_STR, () => { + extension.sendMessage(DUMMY_STR); + }, resolve, resolve); + }); + + win.close(); + + await extension.unload(); +}); + +add_task(async function test_contentscript_clipboard_paste() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "clipboardRead", + ], + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + js: ["shared.js", "content_script.js"], + }], + }, + files: { + "shared.js": shared, + "content_script.js": () => { + browser.test.sendMessage("paste", doPaste()); + }, + }, + }); + + const STRANGE = "A Strange Thing"; + SpecialPowers.clipboardCopyString(STRANGE); + + await extension.startup(); + const win = window.open("file_sample.html"); + + const paste = await extension.awaitMessage("paste"); + is(paste, STRANGE, "the correct string was pasted"); + + win.close(); + await extension.unload(); +}); + +add_task(async function test_background_clipboard_paste() { + function background() { + browser.test.sendMessage("paste", doPaste()); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["clipboardRead"], + }, + background: [shared, background], + }); + + const STRANGE = "Stranger Things"; + SpecialPowers.clipboardCopyString(STRANGE); + + await extension.startup(); + + const paste = await extension.awaitMessage("paste"); + is(paste, STRANGE, "the correct string was pasted"); + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html b/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html new file mode 100644 index 0000000000..b5d5f6764a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_clipboard_image.html @@ -0,0 +1,262 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Clipboard permissions tests</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; +/** + * This cannot be a xpcshell test, because: + * - On Android, copyString of nsIClipboardHelper segfaults because + * widget/android/nsClipboard.cpp calls java::Clipboard::SetText, which is + * unavailable in xpcshell. + * - On Windows, the clipboard is unavailable to xpcshell. + */ + +function resetClipboard() { + SpecialPowers.clipboardCopyString( + "This is the default value of the clipboard in the test."); +} + +async function checkClipboardHasTestImage(imageType) { + async function backgroundScript(imageType) { + async function verifyImage(img) { + // Checks whether the image is a 1x1 red image. + browser.test.assertEq(1, img.naturalWidth, "image width should match"); + browser.test.assertEq(1, img.naturalHeight, "image height should match"); + + let canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + let ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); // Draw without scaling. + let [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + let expectedColor; + if (imageType === "png") { + expectedColor = [255, 0, 0]; + } else if (imageType === "jpeg") { + expectedColor = [254, 0, 0]; + } + let {os} = await browser.runtime.getPlatformInfo(); + if (os === "mac") { + // Due to https://bugzil.la/1396587, the pasted image differs from the + // original/expected image. + // Once that bug is fixed, this whole macOS-only branch can be removed. + if (imageType === "png") { + expectedColor = [255, 38, 0]; + } else if (imageType === "jpeg") { + expectedColor = [255, 38, 0]; + } + } + browser.test.assertEq(expectedColor[0], r, "pixel should be red"); + browser.test.assertEq(expectedColor[1], g, "pixel should not contain green"); + browser.test.assertEq(expectedColor[2], b, "pixel should not contain blue"); + browser.test.assertEq(255, a, "pixel should be opaque"); + } + + let editable = document.body; + editable.contentEditable = true; + let file; + await new Promise(resolve => { + document.addEventListener("paste", function(event) { + browser.test.assertEq(1, event.clipboardData.types.length, "expected one type"); + browser.test.assertEq("Files", event.clipboardData.types[0], "expected type"); + browser.test.assertEq(1, event.clipboardData.files.length, "expected one file"); + + // After returning from the paste event, event.clipboardData is cleaned, so we + // have to store the file in a separate variable. + file = event.clipboardData.files[0]; + resolve(); + }, {once: true}); + + document.execCommand("paste"); // requires clipboardWrite permission. + }); + + // When image data is copied, its first frame is decoded and exported to the + // clipboard. The pasted result is always an unanimated PNG file, regardless + // of the input. + browser.test.assertEq("image/png", file.type, "expected file.type"); + + // event.files[0] should be an accurate representation of the input image. + { + let img = new Image(); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = () => reject(new Error(`Failed to load image ${img.src} of size ${file.size}`)); + img.src = URL.createObjectURL(file); + }); + + await verifyImage(img); + } + + // This confirms that an image was put on the clipboard. + // In contrast, when document.execCommand('copy') + clipboardData.setData + // is used, then the 'paste' event will also have the image data (as tested + // above), but the contentEditable area will be empty. + { + let imgs = editable.querySelectorAll("img"); + browser.test.assertEq(1, imgs.length, "should have pasted one image"); + await verifyImage(imgs[0]); + } + browser.test.sendMessage("tested image on clipboard"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})("${imageType}");`, + manifest: { + permissions: ["clipboardRead"], + }, + }); + await extension.startup(); + await extension.awaitMessage("tested image on clipboard"); + await extension.unload(); +} + +add_task(async function test_without_clipboard_permission() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.assertEq(undefined, browser.clipboard, + "clipboard API requires the clipboardWrite permission."); + browser.test.notifyPass(); + }, + manifest: { + permissions: ["clipboardRead"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_copy_png() { + if (AppConstants.platform === "android") { + return; // Android does not support images on the clipboard. + } + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // A 1x1 red PNG image. + let b64data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEX/AAD///9BHTQRAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg=="; + let imageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer; + await browser.clipboard.setImageData(imageData, "png"); + browser.test.sendMessage("Called setImageData with PNG"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + resetClipboard(); + + await extension.startup(); + await extension.awaitMessage("Called setImageData with PNG"); + await extension.unload(); + + await checkClipboardHasTestImage("png"); +}); + +add_task(async function test_copy_jpeg() { + if (AppConstants.platform === "android") { + return; // Android does not support images on the clipboard. + } + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // A 1x1 red JPEG image, created using: convert xc:red red.jpg. + // JPEG is lossy, and the red pixel value is actually #FE0000 instead of + // #FF0000 (also seen using: convert red.jpg text:-). + let b64data = "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgICAgMCAgIDAwMDBAYEBAQEBAgGBgUGCQgKCgkICQkKDA8MCgsOCwkJDRENDg8QEBEQCgwSExIQEw8QEBD/2wBDAQMDAwQDBAgEBAgQCwkLEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBD/wAARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/xAAVAQEBAAAAAAAAAAAAAAAAAAAHCf/EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhEDEQA/ADoDFU3/2Q=="; + let imageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer; + await browser.clipboard.setImageData(imageData, "jpeg"); + browser.test.sendMessage("Called setImageData with JPEG"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + resetClipboard(); + + await extension.startup(); + await extension.awaitMessage("Called setImageData with JPEG"); + await extension.unload(); + + await checkClipboardHasTestImage("jpeg"); +}); + +add_task(async function test_copy_invalid_image() { + if (AppConstants.platform === "android") { + // Android does not support images on the clipboard. + return; + } + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // This is a PNG image. + let b64data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAABlBMVEX/AAD///9BHTQRAAAACXBIWXMAAA7EAAAOxAGVKw4bAAAACklEQVQImWNgAAAAAgAB9HFkpgAAAABJRU5ErkJggg=="; + let pngImageData = Uint8Array.from(atob(b64data), c => c.charCodeAt(0)).buffer; + await browser.test.assertRejects( + browser.clipboard.setImageData(pngImageData, "jpeg"), + "Data is not a valid jpeg image", + "Image data that is not valid for the given type should be rejected."); + browser.test.sendMessage("finished invalid image"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished invalid image"); + await extension.unload(); +}); + +add_task(async function test_copy_invalid_image_type() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // setImageData expects "png" or "jpeg", but we pass "image/png" here. + browser.test.assertThrows( + () => { browser.clipboard.setImageData(new ArrayBuffer(0), "image/png"); }, + "Type error for parameter imageType (Invalid enumeration value \"image/png\") for clipboard.setImageData.", + "An invalid type for setImageData should be rejected."); + browser.test.sendMessage("finished invalid type"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished invalid type"); + await extension.unload(); +}); + +if (AppConstants.platform === "android") { + add_task(async function test_setImageData_unsupported_on_android() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + // Android does not support images on the clipboard, + // so it should not try to decode an image but fail immediately. + await browser.test.assertRejects( + browser.clipboard.setImageData(new ArrayBuffer(0), "png"), + "Writing images to the clipboard is not supported on Android", + "Should get an error when setImageData is called on Android."); + browser.test.sendMessage("finished unsupported setImageData"); + }, + manifest: { + permissions: ["clipboardWrite"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished unsupported setImageData"); + await extension.unload(); + }); +} + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html new file mode 100644 index 0000000000..04946ceeaf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_about_blank.html @@ -0,0 +1,116 @@ +<!doctype html> +<html> +<head> + <title>Test content script match_about_blank option</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_contentscript_about_blank() { + const manifest = { + content_scripts: [ + { + match_about_blank: true, + matches: ["http://mochi.test/*/file_with_about_blank.html", "http://example.com/*"], + all_frames: true, + css: ["all.css"], + js: ["all.js"], + }, { + matches: ["http://mochi.test/*/file_with_about_blank.html"], + css: ["mochi_without.css"], + js: ["mochi_without.js"], + all_frames: true, + }, { + match_about_blank: true, + matches: ["http://mochi.test/*/file_with_about_blank.html"], + css: ["mochi_with.css"], + js: ["mochi_with.js"], + all_frames: true, + }, + ], + }; + + const files = { + "all.js": function() { + browser.runtime.sendMessage("all"); + }, + "all.css": ` + body { color: red; } + `, + "mochi_without.js": function() { + browser.runtime.sendMessage("mochi_without"); + }, + "mochi_without.css": ` + body { background: yellow; } + `, + "mochi_with.js": function() { + browser.runtime.sendMessage("mochi_with"); + }, + "mochi_with.css": ` + body { text-align: right; } + `, + }; + + function background() { + browser.runtime.onMessage.addListener((script, {url}) => { + const kind = url.startsWith("about:") ? url : "top"; + browser.test.sendMessage("script", [script, kind, url]); + browser.test.sendMessage(`${script}:${kind}`); + }); + } + + const PATH = "tests/toolkit/components/extensions/test/mochitest/file_with_about_blank.html"; + const extension = ExtensionTestUtils.loadExtension({manifest, files, background}); + await extension.startup(); + + let count = 0; + extension.onMessage("script", script => { + info(`script ran: ${script}`); + count++; + }); + + let win = window.open("http://example.com/" + PATH); + await Promise.all([ + extension.awaitMessage("all:top"), + extension.awaitMessage("all:about:blank"), + extension.awaitMessage("all:about:srcdoc"), + ]); + is(count, 3, "exactly 3 scripts ran"); + win.close(); + + win = window.open("http://mochi.test:8888/" + PATH); + await Promise.all([ + extension.awaitMessage("all:top"), + extension.awaitMessage("all:about:blank"), + extension.awaitMessage("all:about:srcdoc"), + extension.awaitMessage("mochi_without:top"), + extension.awaitMessage("mochi_with:top"), + extension.awaitMessage("mochi_with:about:blank"), + extension.awaitMessage("mochi_with:about:srcdoc"), + ]); + + let style = win.getComputedStyle(win.document.body); + is(style.color, "rgb(255, 0, 0)", "top window text color is red"); + is(style.backgroundColor, "rgb(255, 255, 0)", "top window background is yellow"); + is(style.textAlign, "right", "top window text is right-aligned"); + + let a_b = win.document.getElementById("a_b"); + style = a_b.contentWindow.getComputedStyle(a_b.contentDocument.body); + is(style.color, "rgb(255, 0, 0)", "about:blank iframe text color is red"); + is(style.backgroundColor, "rgba(0, 0, 0, 0)", "about:blank iframe background is transparent"); + is(style.textAlign, "right", "about:blank text is right-aligned"); + + is(count, 10, "exactly 7 more scripts ran"); + win.close(); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html new file mode 100644 index 0000000000..306f093fe1 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_activeTab.html @@ -0,0 +1,371 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// Create a test extension with the provided function as the background +// script. The background script will have a few helpful functions +// available. +/* global awaitLoad, gatherFrameSources */ +function makeExtension(background) { + // Wait for a webNavigation.onCompleted event where the details for the + // loaded page match the attributes of `filter`. + function awaitLoad(filter) { + return new Promise(resolve => { + const listener = details => { + if (Object.keys(filter).every(key => details[key] === filter[key])) { + browser.webNavigation.onCompleted.removeListener(listener); + resolve(); + } + }; + browser.webNavigation.onCompleted.addListener(listener); + }); + } + + // Return a string with a (sorted) list of the source of all frames + // in the given tab into which this extension can inject scripts + // (ie all frames for which it has the activeTab permission). + // Source is the hostname for frames in http sources, or the full + // location href in other documents (eg about: pages) + async function gatherFrameSources(tabid) { + let result = await browser.tabs.executeScript(tabid, { + allFrames: true, + matchAboutBlank: true, + code: "window.location.hostname || window.location.href;", + }); + return String(result.sort()); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab", "webNavigation"], + }, + background: `${awaitLoad}\n${gatherFrameSources}\n${ExtensionTestCommon.serializeScript(background)}`, + }); +} + +// Test that executeScript() fails without the activeTab permission +// (or any specific origin permissions). +add_task(async function test_no_activeTab() { + let extension = makeExtension(async function background() { + const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: URL}), + awaitLoad({frameId: 0}), + ]); + + try { + await gatherFrameSources(tab.id); + browser.test.fail("executeScript() should fail without activeTab permission"); + } catch (err) { + browser.test.assertTrue(/^Missing host permission/.test(err.message), + "executeScript() without activeTab permission failed"); + } + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("no-active-tab"); + }); + + await extension.startup(); + await extension.awaitFinish("no-active-tab"); + await extension.unload(); +}); + +// Test that dynamically created iframes do not get the activeTab permission +add_task(async function test_dynamic_frames() { + let extension = makeExtension(async function background() { + const BASE_HOST = "www.example.com"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: `http://${BASE_HOST}/`}), + awaitLoad({frameId: 0}), + ]); + + function inject() { + let nframes = 4; + function frameLoaded() { + nframes--; + if (nframes == 0) { + browser.runtime.sendMessage("frames-loaded"); + } + } + + let frame = document.createElement("iframe"); + frame.addEventListener("load", frameLoaded, {once: true}); + document.body.appendChild(frame); + + let div = document.createElement("div"); + div.innerHTML = "<iframe src='http://test1.example.com/'></iframe>"; + let framelist = div.getElementsByTagName("iframe"); + browser.test.assertEq(1, framelist.length, "Found 1 frame inside div"); + framelist[0].addEventListener("load", frameLoaded, {once: true}); + document.body.appendChild(div); + + let div2 = document.createElement("div"); + div2.innerHTML = "<iframe srcdoc=\"<iframe src='http://test2.example.com/'></iframe>\"></iframe>"; + framelist = div2.getElementsByTagName("iframe"); + browser.test.assertEq(1, framelist.length, "Found 1 frame inside div"); + framelist[0].addEventListener("load", frameLoaded, {once: true}); + document.body.appendChild(div2); + + const URL = "http://www.example.com/tests/toolkit/components/extensions/test/mochitest/file_contentscript_iframe.html"; + + let xhr = new XMLHttpRequest(); + xhr.open("GET", URL); + xhr.responseType = "document"; + xhr.overrideMimeType("text/html"); + + xhr.addEventListener("load", () => { + if (xhr.readyState != 4) { + return; + } + if (xhr.status != 200) { + browser.runtime.sendMessage("error"); + } + + let frame = xhr.response.getElementById("frame"); + browser.test.assertTrue(frame, "Found frame in response document"); + frame.addEventListener("load", frameLoaded, {once: true}); + document.body.appendChild(frame); + }, {once: true}); + xhr.addEventListener("error", () => { + browser.runtime.sendMessage("error"); + }, {once: true}); + xhr.send(); + } + + browser.test.onMessage.addListener(async () => { + let loadedPromise = new Promise((resolve, reject) => { + let listener = msg => { + let unlisten = () => browser.runtime.onMessage.removeListener(listener); + if (msg == "frames-loaded") { + unlisten(); + resolve(); + } else if (msg == "error") { + unlisten(); + reject(); + } + }; + browser.runtime.onMessage.addListener(listener); + }); + + await browser.tabs.executeScript(tab.id, { + code: `(${inject})();`, + }); + + await loadedPromise; + + let result = await gatherFrameSources(tab.id); + browser.test.assertEq(String([BASE_HOST]), result, + "Script is not injected into dynamically created frames"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("dynamic-frames"); + }); + + browser.test.sendMessage("ready", tab.id); + }); + + await extension.startup(); + + let tabId = await extension.awaitMessage("ready"); + extension.grantActiveTab(tabId); + + extension.sendMessage("go"); + await extension.awaitFinish("dynamic-frames"); + + await extension.unload(); +}); + +// Test that an iframe created from an <iframe srcdoc> gets the +// activeTab permission. +add_task(async function test_srcdoc() { + let extension = makeExtension(async function background() { + const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab2.html"; + const OUTER_SOURCE = "about:srcdoc"; + const PAGE_SOURCE = "mochi.test"; + const FRAME_SOURCE = "test1.example.com"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: URL}), + awaitLoad({frameId: 0}), + ]); + + browser.test.onMessage.addListener(async msg => { + if (msg == "go") { + let result = await gatherFrameSources(tab.id); + browser.test.assertEq(String([OUTER_SOURCE, PAGE_SOURCE, FRAME_SOURCE]), + result, + "Script is injected into frame created from <iframe srcdoc>"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("srcdoc"); + } + }); + + browser.test.sendMessage("ready", tab.id); + }); + + await extension.startup(); + + let tabId = await extension.awaitMessage("ready"); + extension.grantActiveTab(tabId); + + extension.sendMessage("go"); + await extension.awaitFinish("srcdoc"); + + await extension.unload(); +}); + +// Test that navigating frames by setting the src attribute from the +// parent page revokes the activeTab permission. +add_task(async function test_navigate_by_src() { + let extension = makeExtension(async function background() { + const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html"; + const PAGE_SOURCE = "mochi.test"; + const EMPTY_SOURCE = "about:blank"; + const FRAME_SOURCE = "test1.example.com"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: URL}), + awaitLoad({frameId: 0}), + ]); + + browser.test.onMessage.addListener(async msg => { + if (msg == "go") { + let result = await gatherFrameSources(tab.id); + browser.test.assertEq(String([EMPTY_SOURCE, PAGE_SOURCE, FRAME_SOURCE]), + result, + "In original page, script is injected into base page and original frames"); + + let loadedPromise = awaitLoad({tabId: tab.id}); + await browser.tabs.executeScript(tab.id, { + code: "document.getElementById('emptyframe').src = 'http://test2.example.com/';", + }); + await loadedPromise; + + result = await gatherFrameSources(tab.id); + browser.test.assertEq(String([PAGE_SOURCE, FRAME_SOURCE]), result, + "Script is not injected into initially empty frame after navigation"); + + loadedPromise = awaitLoad({tabId: tab.id}); + await browser.tabs.executeScript(tab.id, { + code: "document.getElementById('regularframe').src = 'http://test2.example.com/';", + }); + await loadedPromise; + + result = await gatherFrameSources(tab.id); + browser.test.assertEq(String([PAGE_SOURCE]), result, + "Script is not injected into regular frame after navigation"); + + await browser.tabs.remove(tab.id); + browser.test.notifyPass("test-scripts"); + } + }); + + browser.test.sendMessage("ready", tab.id); + }); + + await extension.startup(); + + let tabId = await extension.awaitMessage("ready"); + extension.grantActiveTab(tabId); + + extension.sendMessage("go"); + await extension.awaitFinish("test-scripts"); + + await extension.unload(); +}); + +// Test that navigating frames by setting window.location from inside the +// frame revokes the activeTab permission. +add_task(async function test_navigate_by_window_location() { + let extension = makeExtension(async function background() { + const URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contentscript_activeTab.html"; + const PAGE_SOURCE = "mochi.test"; + const EMPTY_SOURCE = "about:blank"; + const FRAME_SOURCE = "test1.example.com"; + + let [tab] = await Promise.all([ + browser.tabs.create({url: URL}), + awaitLoad({frameId: 0}), + ]); + + browser.test.onMessage.addListener(async msg => { + if (msg == "go") { + let result = await gatherFrameSources(tab.id); + browser.test.assertEq(String([EMPTY_SOURCE, PAGE_SOURCE, FRAME_SOURCE]), + result, + "Script initially injected into all frames"); + + let nframes = 0; + let frames = await browser.webNavigation.getAllFrames({tabId: tab.id}); + for (let frame of frames) { + if (frame.parentFrameId == -1) { + continue; + } + + let loadPromise = awaitLoad({ + tabId: tab.id, + frameId: frame.frameId, + }); + + await browser.tabs.executeScript(tab.id, { + frameId: frame.frameId, + matchAboutBlank: true, + code: "window.location.href = 'https://test2.example.com/';", + }); + await loadPromise; + + try { + result = await browser.tabs.executeScript(tab.id, { + frameId: frame.frameId, + matchAboutBlank: true, + code: "window.location.hostname;", + }); + + browser.test.fail("executeScript should have failed on navigated frame"); + } catch (err) { + browser.test.assertEq("Frame not found, or missing host permission", err.message); + } + + nframes++; + } + browser.test.assertEq(2, nframes, "Found 2 frames"); + + await browser.tabs.remove(tab.id); + browser.test.notifyPass("scripted-navigation"); + } + }); + + browser.test.sendMessage("ready", tab.id); + }); + + await extension.startup(); + + let tabId = await extension.awaitMessage("ready"); + extension.grantActiveTab(tabId); + + extension.sendMessage("go"); + await extension.awaitFinish("scripted-navigation"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html new file mode 100644 index 0000000000..e8bb638d95 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_cache.html @@ -0,0 +1,113 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script caching</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// This file defines content scripts. +/* eslint-env mozilla/frame-script */ + +const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; + +add_task(async function test_contentscript_cache() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }], + + permissions: ["<all_urls>", "tabs"], + }, + + async background() { + // Force our extension instance to be initialized for the current content process. + await browser.tabs.insertCSS({code: ""}); + + browser.test.sendMessage("origin", location.origin); + }, + + files: { + "content_script.js": function() { + browser.test.sendMessage("content-script-loaded"); + }, + }, + }); + + await extension.startup(); + + let origin = await extension.awaitMessage("origin"); + let scriptUrl = `${origin}/content_script.js`; + + let {ExtensionManager} = SpecialPowers.Cu.import("resource://gre/modules/ExtensionChild.jsm", {}); + let ext = ExtensionManager.extensions.get(extension.id); + + ext.staticScripts.expiryTimeout = 3000; + is(ext.staticScripts.size, 0, "Should have no cached scripts"); + + let win = window.open(`${BASE}/file_sample.html`); + await extension.awaitMessage("content-script-loaded"); + + if (AppConstants.platform !== "android") { + is(ext.staticScripts.size, 1, "Should have one cached script"); + ok(ext.staticScripts.has(scriptUrl), "Script cache should contain script URL"); + } + + let chromeScript, chromeScriptDone; + let {appinfo} = SpecialPowers.Services; + if (appinfo.processType === appinfo.PROCESS_TYPE_CONTENT) { + /* globals addMessageListener, assert */ + chromeScript = SpecialPowers.loadChromeScript(() => { + addMessageListener("check-script-cache", extensionId => { + let {ExtensionManager} = ChromeUtils.import("resource://gre/modules/ExtensionChild.jsm", null); + let ext = ExtensionManager.extensions.get(extensionId); + + if (ext && ext.staticScripts) { + assert.equal(ext.staticScripts.size, 0, "Should have no cached scripts in the parent process"); + } + + sendAsyncMessage("done"); + }); + }); + chromeScript.sendAsyncMessage("check-script-cache", extension.id); + chromeScriptDone = chromeScript.promiseOneMessage("done"); + } + + SimpleTest.requestFlakyTimeout("Required to test expiry timeout"); + await new Promise(resolve => setTimeout(resolve, 3000)); + is(ext.staticScripts.size, 0, "Should have no cached scripts"); + + if (chromeScript) { + await chromeScriptDone; + chromeScript.destroy(); + } + + win.close(); + + win = window.open(`${BASE}/file_sample.html`); + await extension.awaitMessage("content-script-loaded"); + + is(ext.staticScripts.size, 1, "Should have one cached script"); + ok(ext.staticScripts.has(scriptUrl)); + + SpecialPowers.Services.obs.notifyObservers(null, "memory-pressure", "heap-minimize"); + + is(ext.staticScripts.size, 0, "Should have no cached scripts after heap-minimize"); + + win.close(); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html new file mode 100644 index 0000000000..c4b6b5a256 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_canvas.html @@ -0,0 +1,138 @@ +<!doctype html> +<html> +<head> + <title>Test content script access to canvas drawWindow()</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<script> +"use strict"; + +add_task(async function test_drawWindow() { + const permissions = [ + "<all_urls>", + ]; + + const content_scripts = [{ + matches: ["https://example.org/*"], + js: ["content_script.js"], + }]; + + const files = { + "content_script.js": () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + try { + ctx.drawWindow(window, 0, 0, 10, 10, "red"); + const {data} = ctx.getImageData(0, 0, 10, 10); + browser.test.sendMessage("success", data.slice(0, 3).join()); + } catch (e) { + browser.test.sendMessage("error", e.message); + } + }, + }; + + const first = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "draw_window_first@tests.mozilla.org" } }, + permissions, + content_scripts + }, + files + }); + const second = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "draw_window_second@tests.mozilla.org" } }, + content_scripts + }, + files + }); + + await first.startup(); + await second.startup(); + + const win = window.open("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html"); + + const colour = await first.awaitMessage("success"); + is(colour, "255,255,153", "drawWindow() call was successful: #ff9 == rgb(255,255,153)"); + + const error = await second.awaitMessage("error"); + is(error, "ctx.drawWindow is not a function", "drawWindow() method not awailable without permission"); + + win.close(); + await first.unload(); + await second.unload(); +}); + +add_task(async function test_tainted_canvas() { + const permissions = [ + "<all_urls>", + ]; + + const content_scripts = [{ + matches: ["https://example.org/*"], + js: ["content_script.js"], + }]; + + const files = { + "content_script.js": () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + const img = new Image(); + + img.onload = function() { + ctx.drawImage(img, 0, 0); + try { + const png = canvas.toDataURL(); + const {data} = ctx.getImageData(0, 0, 10, 10); + browser.test.sendMessage("success", {png, colour: data.slice(0, 4).join()}); + } catch (e) { + browser.test.log(`Exception: ${e.message}`); + browser.test.sendMessage("error", e.message); + } + }; + + // Cross-origin image from example.com. + img.src = "https://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_good.png"; + }, + }; + + const first = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "draw_window_first@tests.mozilla.org" } }, + permissions, + content_scripts + }, + files + }); + const second = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "draw_window_second@tests.mozilla.org" } }, + content_scripts + }, + files + }); + + await first.startup(); + await second.startup(); + + const win = window.open("https://example.org/tests/toolkit/components/extensions/test/mochitest/file_to_drawWindow.html"); + + const {png, colour} = await first.awaitMessage("success"); + ok(png.startsWith("data:image/png;base64,"), "toDataURL() call was successful."); + is(colour, "0,0,0,0", "getImageData() returned the correct colour (transparent)."); + + const error = await second.awaitMessage("error"); + is(error, "The operation is insecure.", "toDataURL() throws without permission."); + + win.close(); + await first.unload(); + await second.unload(); +}); + +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html new file mode 100644 index 0000000000..f2a2de0e05 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_devtools_metadata.html @@ -0,0 +1,77 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for Sandbox metadata on WebExtensions ContentScripts</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_contentscript_devtools_sandbox_metadata() { + function contentScript() { + browser.runtime.sendMessage("contentScript.executed"); + } + + function background() { + browser.runtime.onMessage.addListener((msg) => { + if (msg == "contentScript.executed") { + browser.test.notifyPass("contentScript.executed"); + } + }); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }, + ], + }, + + background, + files: { + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let win = window.open("file_sample.html"); + + let innerWindowID = SpecialPowers.wrap(win).windowGlobalChild.innerWindowId; + + await extension.awaitFinish("contentScript.executed"); + + const {ExtensionContent} = SpecialPowers.Cu.import( + "resource://gre/modules/ExtensionContent.jsm", {} + ); + + let res = ExtensionContent.getContentScriptGlobals(win); + is(res.length, 1, "Got the expected array of globals"); + let metadata = SpecialPowers.Cu.getSandboxMetadata(res[0]) || {}; + + is(metadata.addonId, extension.id, "Got the expected addonId"); + is(metadata["inner-window-id"], innerWindowID, "Got the expected inner-window-id"); + + await extension.unload(); + info("extension unloaded"); + + res = ExtensionContent.getContentScriptGlobals(win); + is(res.length, 0, "No content scripts globals found once the extension is unloaded"); + + win.close(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html new file mode 100644 index 0000000000..702456a798 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_fission_frame.html @@ -0,0 +1,100 @@ +<!doctype html> +<head> + <title>Test content script in cross-origin frame</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<script> +"use strict"; + +add_task(async function test_content_script_cross_origin_frame() { + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + matches: ["http://example.net/*/file_sample.html"], + all_frames: true, + js: ["cs.js"], + }], + permissions: ["http://example.net/"], + }, + + background() { + browser.runtime.onConnect.addListener(port => { + port.onMessage.addListener(async num => { + let { tab, url, frameId } = port.sender; + + browser.test.assertTrue(frameId > 0, "sender frameId is ok"); + browser.test.assertTrue(url.endsWith("file_sample.html"), "url is ok"); + + let shared = await browser.tabs.executeScript(tab.id, { + allFrames: true, + code: `window.sharedVal`, + }); + browser.test.assertEq(shared[0], 357, "CS runs in a shared Sandbox"); + + let code = "does.not.exist"; + await browser.test.assertRejects( + browser.tabs.executeScript(tab.id, { allFrames: true, code }), + /does is not defined/, + "Got the expected rejection from tabs.executeScript" + ); + + code = "() => {}"; + await browser.test.assertRejects( + browser.tabs.executeScript(tab.id, { allFrames: true, code }), + /Script .* result is non-structured-clonable data/, + "Got the expected rejection from tabs.executeScript" + ); + + let result = await browser.tabs.sendMessage(tab.id, num); + port.postMessage(result); + port.disconnect(); + }); + }); + }, + + files: { + "cs.js"() { + let text = document.body.innerText; + browser.test.assertEq(text, "Sample text", "CS can access page DOM"); + + let manifest = browser.runtime.getManifest(); + browser.test.assertEq(manifest.version, "1.0"); + browser.test.assertEq(manifest.name, "Generated extension"); + + browser.runtime.onMessage.addListener(async num => { + browser.test.log("content script received tabs.sendMessage"); + return num * 3; + }) + + let response; + window.sharedVal = 357; + + let port = browser.runtime.connect(); + port.onMessage.addListener(num => { + response = num; + }); + port.onDisconnect.addListener(() => { + browser.test.assertEq(response, 21, "Got correct response"); + browser.test.notifyPass(); + }); + port.postMessage(7); + }, + }, + }); + + await extension.startup(); + + let base = "http://example.org/tests/toolkit/components/extensions/test"; + let win = window.open(`${base}/mochitest/file_with_xorigin_frame.html`); + + await extension.awaitFinish(); + win.close(); + + await extension.unload(); +}); + +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html new file mode 100644 index 0000000000..63dd23b151 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_incognito.html @@ -0,0 +1,105 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script private browsing ID</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ChromeTask.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function test_contentscript_incognito() { + await SpecialPowers.pushPrefEnv({set: [ + ["extensions.allowPrivateBrowsingByDefault", false], + ]}); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + }, + ], + }, + + background() { + let windowId; + + browser.test.onMessage.addListener(([msg, url]) => { + if (msg === "open-window") { + browser.windows.create({url, incognito: true}).then(window => { + windowId = window.id; + }); + } else if (msg === "close-window") { + browser.windows.remove(windowId).then(() => { + browser.test.sendMessage("done"); + }); + } + }); + }, + + files: { + "content_script.js": async () => { + const COOKIE = "foo=florgheralzps"; + document.cookie = COOKIE; + + let url = new URL("return_headers.sjs", location.href); + + let responses = [ + new Promise(resolve => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.onload = () => resolve(JSON.parse(xhr.responseText)); + xhr.send(); + }), + + fetch(url, {credentials: "include"}).then(body => body.json()), + ]; + + try { + for (let response of await Promise.all(responses)) { + browser.test.assertEq(COOKIE, response.cookie, "Got expected cookie header"); + } + browser.test.notifyPass("cookies"); + } catch (e) { + browser.test.fail(`Error: ${e}`); + browser.test.notifyFail("cookies"); + } + }, + }, + }); + + await extension.startup(); + + extension.sendMessage(["open-window", SimpleTest.getTestFileURL("file_sample.html")]); + + await extension.awaitFinish("cookies"); + + extension.sendMessage(["close-window"]); + await extension.awaitMessage("done"); + + await extension.unload(); +} + +add_task(async function() { + await test_contentscript_incognito(); +}); + +add_task(async function() { + await SpecialPowers.pushPrefEnv({set: [ + ["network.cookie.cookieBehavior", 3], + ]}); + await test_contentscript_incognito(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html new file mode 100644 index 0000000000..e6bc48800c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_contentscript_permission.html @@ -0,0 +1,61 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_contentscript() { + function background() { + browser.test.onMessage.addListener(async url => { + let tab = await browser.tabs.create({url}); + + let executed = true; + try { + await browser.tabs.executeScript(tab.id, {code: "true;"}); + } catch (e) { + executed = false; + } + + await browser.tabs.remove([tab.id]); + browser.test.sendMessage("executed", executed); + }); + } + + let extensionData = { + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "contentscript@tests.mozilla.org" } }, + permissions: ["<all_urls>"], + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + extension.sendMessage("https://example.com"); + let result = await extension.awaitMessage("executed"); + is(result, true, "Content script can be run in a page without mozAddonManager"); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webapi.testing", true]], + }); + + extension.sendMessage("https://example.com"); + result = await extension.awaitMessage("executed"); + is(result, false, "Content script cannot be run in a page with mozAddonManager"); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html new file mode 100644 index 0000000000..c9dd05a41c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies.html @@ -0,0 +1,366 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_cookies() { + await SpecialPowers.pushPrefEnv({set: [ + ["extensions.allowPrivateBrowsingByDefault", false], + ]}); + + async function background() { + function assertExpected(expected, cookie) { + for (let key of Object.keys(cookie)) { + browser.test.assertTrue(key in expected, `found property ${key}`); + browser.test.assertEq(expected[key], cookie[key], `property value for ${key} is correct`); + } + browser.test.assertEq(Object.keys(expected).length, Object.keys(cookie).length, "all expected properties found"); + } + + async function getDocumentCookie(tabId) { + let results = await browser.tabs.executeScript(tabId, { + code: "document.cookie", + }); + browser.test.assertEq(1, results.length, "executeScript returns one result"); + return results[0]; + } + + async function testIpCookie(ipAddress, setHostOnly) { + const IP_TEST_HOST = ipAddress; + const IP_TEST_URL = `http://${IP_TEST_HOST}/`; + const IP_THE_FUTURE = Date.now() + 5 * 60; + const IP_STORE_ID = "firefox-default"; + + let expectedCookie = { + name: "name1", + value: "value1", + domain: IP_TEST_HOST, + hostOnly: true, + path: "/", + secure: false, + httpOnly: false, + sameSite: "no_restriction", + session: false, + expirationDate: IP_THE_FUTURE, + storeId: IP_STORE_ID, + firstPartyDomain: "", + }; + + await browser.browsingData.removeCookies({}); + let ip_cookie = await browser.cookies.set({ + url: IP_TEST_URL, + domain: setHostOnly ? ipAddress : undefined, + name: "name1", + value: "value1", + expirationDate: IP_THE_FUTURE, + }); + assertExpected(expectedCookie, ip_cookie); + + let ip_cookies = await browser.cookies.getAll({name: "name1"}); + browser.test.assertEq(1, ip_cookies.length, "ip cookie can be added"); + assertExpected(expectedCookie, ip_cookies[0]); + + ip_cookies = await browser.cookies.getAll({domain: IP_TEST_HOST, name: "name1"}); + browser.test.assertEq(1, ip_cookies.length, "can get ip cookie by host"); + assertExpected(expectedCookie, ip_cookies[0]); + + let ip_details = await browser.cookies.remove({url: IP_TEST_URL, name: "name1"}); + assertExpected({url: IP_TEST_URL, name: "name1", storeId: IP_STORE_ID, firstPartyDomain: ""}, ip_details); + + ip_cookies = await browser.cookies.getAll({name: "name1"}); + browser.test.assertEq(0, ip_cookies.length, "ip cookie can be removed"); + } + + async function openPrivateWindowAndTab(TEST_URL) { + // Add some random suffix to make sure that we select the right tab. + const PRIVATE_TEST_URL = TEST_URL + "?random" + Math.random(); + + let tabReadyPromise = new Promise((resolve) => { + browser.webNavigation.onDOMContentLoaded.addListener(function listener({tabId}) { + browser.webNavigation.onDOMContentLoaded.removeListener(listener); + resolve(tabId); + }, { + url: [{ + urlPrefix: PRIVATE_TEST_URL, + }], + }); + }); + // This tab is opened for two purposes: + // 1. To allow tests to run content scripts in the context of a tab, + // for fetching the value of document.cookie. + // 2. TODO Bug 1309637 To work around cookies in incognito windows, + // based on the analysis in comment 8. + let {id: windowId} = await browser.windows.create({ + incognito: true, + url: PRIVATE_TEST_URL, + }); + let tabId = await tabReadyPromise; + return {windowId, tabId}; + } + + function changePort(href, port) { + let url = new URL(href); + url.port = port; + return url.href; + } + + await testIpCookie("[2a03:4000:6:310e:216:3eff:fe53:99b]", false); + await testIpCookie("[2a03:4000:6:310e:216:3eff:fe53:99b]", true); + await testIpCookie("192.168.1.1", false); + await testIpCookie("192.168.1.1", true); + + const TEST_URL = "http://example.org/"; + const TEST_SECURE_URL = "https://example.org/"; + const THE_FUTURE = Date.now() + 5 * 60; + const TEST_PATH = "set_path"; + const TEST_URL_WITH_PATH = TEST_URL + TEST_PATH; + const TEST_COOKIE_PATH = `/${TEST_PATH}`; + const STORE_ID = "firefox-default"; + const PRIVATE_STORE_ID = "firefox-private"; + + let expected = { + name: "name1", + value: "value1", + domain: "example.org", + hostOnly: true, + path: "/", + secure: false, + httpOnly: false, + sameSite: "no_restriction", + session: false, + expirationDate: THE_FUTURE, + storeId: STORE_ID, + firstPartyDomain: "", + }; + + // Remove all cookies before starting the test. + await browser.browsingData.removeCookies({}); + + let cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", expirationDate: THE_FUTURE}); + assertExpected(expected, cookie); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + assertExpected(expected, cookie); + + let cookies = await browser.cookies.getAll({name: "name1"}); + browser.test.assertEq(1, cookies.length, "one cookie found for matching name"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({domain: "example.org"}); + browser.test.assertEq(1, cookies.length, "one cookie found for matching domain"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({domain: "example.net"}); + browser.test.assertEq(0, cookies.length, "no cookies found for non-matching domain"); + + cookies = await browser.cookies.getAll({secure: false}); + browser.test.assertEq(1, cookies.length, "one non-secure cookie found"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({secure: true}); + browser.test.assertEq(0, cookies.length, "no secure cookies found"); + + cookies = await browser.cookies.getAll({storeId: STORE_ID}); + browser.test.assertEq(1, cookies.length, "one cookie found for valid storeId"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({storeId: "invalid_id"}); + browser.test.assertEq(0, cookies.length, "no cookies found for invalid storeId"); + + let details = await browser.cookies.remove({url: TEST_URL, name: "name1"}); + assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + // Ports in cookie URLs should be ignored. Every API call uses a different port number for better coverage. + cookie = await browser.cookies.set({url: changePort(TEST_URL, 1234), name: "name1", value: "value1", expirationDate: THE_FUTURE}); + assertExpected(expected, cookie); + + cookie = await browser.cookies.get({url: changePort(TEST_URL, 65535), name: "name1"}); + assertExpected(expected, cookie); + + cookies = await browser.cookies.getAll({url: TEST_URL}); + browser.test.assertEq(cookies.length, 1, "Found cookie using getAll without port"); + assertExpected(expected, cookies[0]); + + cookies = await browser.cookies.getAll({url: changePort(TEST_URL, 1)}); + browser.test.assertEq(cookies.length, 1, "Found cookie using getAll with port"); + assertExpected(expected, cookies[0]); + + // .remove should return the URL of the API call, so the port is included in the return value. + const TEST_URL_TO_REMOVE = changePort(TEST_URL, 1023); + details = await browser.cookies.remove({url: TEST_URL_TO_REMOVE, name: "name1"}); + assertExpected({url: TEST_URL_TO_REMOVE, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + let stores = await browser.cookies.getAllCookieStores(); + browser.test.assertEq(1, stores.length, "expected number of stores returned"); + browser.test.assertEq(STORE_ID, stores[0].id, "expected store id returned"); + browser.test.assertEq(1, stores[0].tabIds.length, "one tabId returned for store"); + browser.test.assertEq("number", typeof stores[0].tabIds[0], "tabId is a number"); + + // TODO bug 1372178: Opening private windows/tabs is not supported on Android + if (browser.windows) { + let {windowId} = await openPrivateWindowAndTab(TEST_URL); + let stores = await browser.cookies.getAllCookieStores(); + + browser.test.assertEq(2, stores.length, "expected number of stores returned"); + browser.test.assertEq(STORE_ID, stores[0].id, "expected store id returned"); + browser.test.assertEq(1, stores[0].tabIds.length, "one tab returned for store"); + browser.test.assertEq(PRIVATE_STORE_ID, stores[1].id, "expected private store id returned"); + browser.test.assertEq(1, stores[0].tabIds.length, "one tab returned for private store"); + + await browser.windows.remove(windowId); + } + + cookie = await browser.cookies.set({url: TEST_URL, name: "name2", domain: ".example.org", expirationDate: THE_FUTURE}); + browser.test.assertEq(false, cookie.hostOnly, "cookie is not a hostOnly cookie"); + + details = await browser.cookies.remove({url: TEST_URL, name: "name2"}); + assertExpected({url: TEST_URL, name: "name2", storeId: STORE_ID, firstPartyDomain: ""}, details); + + // Create a session cookie. + cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1"}); + browser.test.assertEq(true, cookie.session, "session cookie set"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(true, cookie.session, "got session cookie"); + + cookies = await browser.cookies.getAll({session: true}); + browser.test.assertEq(1, cookies.length, "one session cookie found"); + browser.test.assertEq(true, cookies[0].session, "found session cookie"); + + cookies = await browser.cookies.getAll({session: false}); + browser.test.assertEq(0, cookies.length, "no non-session cookies found"); + + details = await browser.cookies.remove({url: TEST_URL, name: "name1"}); + assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + cookie = await browser.cookies.set({url: TEST_SECURE_URL, name: "name1", value: "value1", secure: true}); + browser.test.assertEq(true, cookie.secure, "secure cookie set"); + + cookie = await browser.cookies.get({url: TEST_SECURE_URL, name: "name1"}); + browser.test.assertEq(true, cookie.session, "got secure cookie"); + + cookies = await browser.cookies.getAll({secure: true}); + browser.test.assertEq(1, cookies.length, "one secure cookie found"); + browser.test.assertEq(true, cookies[0].secure, "found secure cookie"); + + cookies = await browser.cookies.getAll({secure: false}); + browser.test.assertEq(0, cookies.length, "no non-secure cookies found"); + + details = await browser.cookies.remove({url: TEST_SECURE_URL, name: "name1"}); + assertExpected({url: TEST_SECURE_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details); + + cookie = await browser.cookies.get({url: TEST_SECURE_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + cookie = await browser.cookies.set({url: TEST_URL_WITH_PATH, path: TEST_COOKIE_PATH, name: "name1", value: "value1", expirationDate: THE_FUTURE}); + browser.test.assertEq(TEST_COOKIE_PATH, cookie.path, "created cookie with path"); + + cookie = await browser.cookies.get({url: TEST_URL_WITH_PATH, name: "name1"}); + browser.test.assertEq(TEST_COOKIE_PATH, cookie.path, "got cookie with path"); + + cookies = await browser.cookies.getAll({path: TEST_COOKIE_PATH}); + browser.test.assertEq(1, cookies.length, "one cookie with path found"); + browser.test.assertEq(TEST_COOKIE_PATH, cookies[0].path, "found cookie with path"); + + cookie = await browser.cookies.get({url: TEST_URL + "invalid_path", name: "name1"}); + browser.test.assertEq(null, cookie, "get with invalid path returns null"); + + cookies = await browser.cookies.getAll({path: "/invalid_path"}); + browser.test.assertEq(0, cookies.length, "getAll with invalid path returns 0 cookies"); + + details = await browser.cookies.remove({url: TEST_URL_WITH_PATH, name: "name1"}); + assertExpected({url: TEST_URL_WITH_PATH, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details); + + cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", httpOnly: true}); + browser.test.assertEq(true, cookie.httpOnly, "httpOnly cookie set"); + + cookie = await browser.cookies.set({url: TEST_URL, name: "name1", value: "value1", httpOnly: false}); + browser.test.assertEq(false, cookie.httpOnly, "non-httpOnly cookie set"); + + details = await browser.cookies.remove({url: TEST_URL, name: "name1"}); + assertExpected({url: TEST_URL, name: "name1", storeId: STORE_ID, firstPartyDomain: ""}, details); + + cookie = await browser.cookies.set({url: TEST_URL}); + browser.test.assertEq("", cookie.name, "default name set"); + browser.test.assertEq("", cookie.value, "default value set"); + browser.test.assertEq(true, cookie.session, "no expiry date created session cookie"); + + // TODO bug 1372178: Opening private windows/tabs is not supported on Android + if (browser.windows) { + let {tabId, windowId} = await openPrivateWindowAndTab(TEST_URL); + + browser.test.assertEq("", await getDocumentCookie(tabId), "initially no cookie"); + + let cookie = await browser.cookies.set({url: TEST_URL, name: "store", value: "private", expirationDate: THE_FUTURE, storeId: PRIVATE_STORE_ID}); + browser.test.assertEq("private", cookie.value, "set the private cookie"); + + cookie = await browser.cookies.set({url: TEST_URL, name: "store", value: "default", expirationDate: THE_FUTURE, storeId: STORE_ID}); + browser.test.assertEq("default", cookie.value, "set the default cookie"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID}); + browser.test.assertEq("private", cookie.value, "get the private cookie"); + browser.test.assertEq(PRIVATE_STORE_ID, cookie.storeId, "get the private cookie storeId"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: STORE_ID}); + browser.test.assertEq("default", cookie.value, "get the default cookie"); + browser.test.assertEq(STORE_ID, cookie.storeId, "get the default cookie storeId"); + + browser.test.assertEq("store=private", await getDocumentCookie(tabId), "private document.cookie should be set"); + + let details = await browser.cookies.remove({url: TEST_URL, name: "store", storeId: STORE_ID}); + assertExpected({url: TEST_URL, name: "store", storeId: STORE_ID, firstPartyDomain: ""}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: STORE_ID}); + browser.test.assertEq(null, cookie, "deleted the default cookie"); + + details = await browser.cookies.remove({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID}); + assertExpected({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID, firstPartyDomain: ""}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "store", storeId: PRIVATE_STORE_ID}); + browser.test.assertEq(null, cookie, "deleted the private cookie"); + + browser.test.assertEq("", await getDocumentCookie(tabId), "private document.cookie should be removed"); + + await browser.windows.remove(windowId); + } + + browser.test.notifyPass("cookies"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + incognitoOverride: "spanning", + background, + manifest: { + applications: { gecko: { id: "cookies@tests.mozilla.org" } }, + permissions: ["cookies", "*://example.org/", "*://[2a03:4000:6:310e:216:3eff:fe53:99b]/", "*://192.168.1.1/", "webNavigation", "browsingData"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("cookies"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html new file mode 100644 index 0000000000..db12a97854 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_containers.html @@ -0,0 +1,97 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + // make sure userContext is enabled. + await SpecialPowers.pushPrefEnv({"set": [ + ["privacy.userContext.enabled", true], + ]}); +}); + +add_task(async function test_cookie_containers() { + async function background() { + // Sometimes there is a cookie without name/value when running tests. + let cookiesAtStart = await browser.cookies.getAll({storeId: "firefox-default"}); + + function assertExpected(expected, cookie) { + for (let key of Object.keys(cookie)) { + browser.test.assertTrue(key in expected, `found property ${key}`); + browser.test.assertEq(expected[key], cookie[key], `property value for ${key} is correct`); + } + browser.test.assertEq(Object.keys(expected).length, Object.keys(cookie).length, "all expected properties found"); + } + + const TEST_URL = "http://example.org/"; + const THE_FUTURE = Date.now() + 5 * 60; + + let expected = { + name: "name1", + value: "value1", + domain: "example.org", + hostOnly: true, + path: "/", + secure: false, + httpOnly: false, + sameSite: "no_restriction", + session: false, + expirationDate: THE_FUTURE, + storeId: "firefox-container-1", + firstPartyDomain: "", + }; + + let cookie = await browser.cookies.set({ + url: TEST_URL, name: "name1", value: "value1", + expirationDate: THE_FUTURE, storeId: "firefox-container-1", + }); + browser.test.assertEq("firefox-container-1", cookie.storeId, "the cookie has the correct storeId"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1"}); + browser.test.assertEq(null, cookie, "get() without storeId returns null"); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1", storeId: "firefox-container-1"}); + assertExpected(expected, cookie); + + let cookies = await browser.cookies.getAll({storeId: "firefox-default"}); + browser.test.assertEq(0, cookiesAtStart.length - cookies.length, "getAll() with default storeId hasn't added cookies"); + + cookies = await browser.cookies.getAll({storeId: "firefox-container-1"}); + browser.test.assertEq(1, cookies.length, "one cookie found for matching domain"); + assertExpected(expected, cookies[0]); + + let details = await browser.cookies.remove({url: TEST_URL, name: "name1", storeId: "firefox-container-1"}); + assertExpected({url: TEST_URL, name: "name1", storeId: "firefox-container-1", firstPartyDomain: ""}, details); + + cookie = await browser.cookies.get({url: TEST_URL, name: "name1", storeId: "firefox-container-1"}); + browser.test.assertEq(null, cookie, "removed cookie not found"); + + browser.test.notifyPass("cookies"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://example.org/"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("cookies"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html new file mode 100644 index 0000000000..fa118f5271 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_expiry.html @@ -0,0 +1,72 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension cookies test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_cookies_expiry() { + function background() { + let expectedEvents = []; + + browser.cookies.onChanged.addListener(event => { + expectedEvents.push(`${event.removed}:${event.cause}`); + if (expectedEvents.length === 1) { + browser.test.assertEq("true:expired", expectedEvents[0], "expired cookie removed"); + browser.test.assertEq("first", event.cookie.name, "expired cookie has the expected name"); + browser.test.assertEq("one", event.cookie.value, "expired cookie has the expected value"); + } else { + browser.test.assertEq("false:explicit", expectedEvents[1], "new cookie added"); + browser.test.assertEq("first", event.cookie.name, "new cookie has the expected name"); + browser.test.assertEq("one-again", event.cookie.value, "new cookie has the expected value"); + browser.test.notifyPass("cookie-expiry"); + } + }); + + setTimeout(() => { + browser.test.sendMessage("change-cookies"); + }, 1000); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["http://example.com/", "cookies"], + }, + background, + }); + + let chromeScript = loadChromeScript(() => { + const {sendAsyncMessage} = this; + Services.cookies.add(".example.com", "/", "first", "one", false, false, false, Date.now() / 1000 + 1, {}, Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_HTTP); + sendAsyncMessage("done"); + }); + await chromeScript.promiseOneMessage("done"); + chromeScript.destroy(); + + await extension.startup(); + await extension.awaitMessage("change-cookies"); + + chromeScript = loadChromeScript(() => { + const {sendAsyncMessage} = this; + Services.cookies.add(".example.com", "/", "first", "one-again", false, false, false, Date.now() / 1000 + 10, {}, Ci.nsICookie.SAMESITE_NONE, Ci.nsICookie.SCHEME_HTTP); + sendAsyncMessage("done"); + }); + await chromeScript.promiseOneMessage("done"); + chromeScript.destroy(); + + await extension.awaitFinish("cookie-expiry"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html new file mode 100644 index 0000000000..7e33f4731d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_first_party.html @@ -0,0 +1,316 @@ +<!DOCTYPE html> +<meta charset="utf-8"> +<script src="/tests/SimpleTest/SimpleTest.js"></script> +<script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> +<script src="head.js"></script> +<script> +"use strict"; + +async function background() { + const url = "http://ext-cookie-first-party.mochi.test/"; + const firstPartyDomain = "ext-cookie-first-party.mochi.test"; + // A first party domain with invalid characters for the file system, which just happens to be a IPv6 address. + const firstPartyDomainInvalidChars = "[2606:4700:4700::1111]"; + const expectedError = "First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set."; + + const assertExpectedCookies = (expected, cookies, message) => { + let matches = (cookie, expected) => { + if (!cookie || !expected) { + return cookie === expected; // true if both are null. + } + for (let key of Object.keys(expected)) { + if (cookie[key] !== expected[key]) { + return false; + } + } + return true; + }; + browser.test.assertEq(expected.length, cookies.length, `Got expected number of cookies - ${message}`); + if (cookies.length !== expected.length) { + return; + } + for (let expect of expected) { + let foundCookies = cookies.filter(cookie => matches(cookie, expect)); + browser.test.assertEq(1, foundCookies.length, + `Expected cookie ${JSON.stringify(expect)} found - ${message}`); + } + }; + + // Test when FPI is disabled. + const test_fpi_disabled = async () => { + let cookie, cookies; + + // set + cookie = await browser.cookies.set({url, name: "foo1", value: "bar1"}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "set: FPI off, w/ empty firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.set({url, name: "foo2", value: "bar2", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + ], [cookie], "set: FPI off, w/ firstPartyDomain, FP cookie"); + + // get + // When FPI is disabled, missing key/null/undefined is equivalent to "". + cookie = await browser.cookies.get({url, name: "foo1"}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI off, w/o firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: ""}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI off, w/ empty firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: null}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI off, w/ null firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: undefined}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI off, w/ undefined firstPartyDomain, non-FP cookie"); + + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + ], [cookie], "get: FPI off, w/ firstPartyDomain, FP cookie"); + // There is no match for non-FP cookies with name "foo2". + cookie = await browser.cookies.get({url, name: "foo2"}); + assertExpectedCookies([null], [cookie], "get: FPI off, w/o firstPartyDomain, no cookie"); + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: ""}); + assertExpectedCookies([null], [cookie], "get: FPI off, w/ empty firstPartyDomain, no cookie"); + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: null}); + assertExpectedCookies([null], [cookie], "get: FPI off, w/ null firstPartyDomain, no cookie"); + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain: undefined}); + assertExpectedCookies([null], [cookie], "get: FPI off, w/ undefined firstPartyDomain, no cookie"); + + // getAll + for (let extra of [{}, {url}, {domain: firstPartyDomain}]) { + const prefix = `getAll(${JSON.stringify(extra)})`; + cookies = await browser.cookies.getAll({...extra}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], cookies, `${prefix}: FPI off, w/o firstPartyDomain, non-FP cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: ""}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], cookies, `${prefix}: FPI off, w/ empty firstPartyDomain, non-FP cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: null}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + {name: "foo2", value: "bar2", firstPartyDomain}, + ], cookies, `${prefix}: FPI off, w/ null firstPartyDomain, all cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: undefined}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + {name: "foo2", value: "bar2", firstPartyDomain}, + ], cookies, `${prefix}: FPI off, w/ undefined firstPartyDomain, all cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + ], cookies, `${prefix}: FPI off, w/ firstPartyDomain, FP cookies`); + } + + // remove + cookie = await browser.cookies.remove({url, name: "foo1"}); + assertExpectedCookies([ + {url, name: "foo1", firstPartyDomain: ""}, + ], [cookie], "remove: FPI off, w/ empty firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.remove({url, name: "foo2", firstPartyDomain}); + assertExpectedCookies([ + {url, name: "foo2", firstPartyDomain}, + ], [cookie], "remove: FPI off, w/ firstPartyDomain, FP cookie"); + + // Test if FP cookies set when FPI off can be accessed when FPI on. + await browser.cookies.set({url, name: "foo1", value: "bar1"}); + await browser.cookies.set({url, name: "foo2", value: "bar2", firstPartyDomain}); + + browser.test.sendMessage("test_fpi_disabled"); + }; + + // Test when FPI is enabled. + const test_fpi_enabled = async () => { + let cookie, cookies; + + // set + await browser.test.assertRejects( + browser.cookies.set({url, name: "foo3", value: "bar3"}), + expectedError, + "set: FPI on, w/o firstPartyDomain, rejection"); + cookie = await browser.cookies.set({url, name: "foo4", value: "bar4", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo4", value: "bar4", firstPartyDomain}, + ], [cookie], "set: FPI on, w/ firstPartyDomain, FP cookie"); + + // get + await browser.test.assertRejects( + browser.cookies.get({url, name: "foo3"}), + expectedError, + "get: FPI on, w/o firstPartyDomain, rejection"); + await browser.test.assertRejects( + browser.cookies.get({url, name: "foo3", firstPartyDomain: null}), + expectedError, + "get: FPI on, w/ null firstPartyDomain, rejection"); + await browser.test.assertRejects( + browser.cookies.get({url, name: "foo3", firstPartyDomain: undefined}), + expectedError, + "get: FPI on, w/ undefined firstPartyDomain, rejection"); + cookie = await browser.cookies.get({url, name: "foo1", firstPartyDomain: ""}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], [cookie], "get: FPI on, w/ empty firstPartyDomain, non-FP cookie"); + cookie = await browser.cookies.get({url, name: "foo4", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo4", value: "bar4", firstPartyDomain}, + ], [cookie], "get: FPI on, w/ firstPartyDomain, FP cookie"); + cookie = await browser.cookies.get({url, name: "foo2", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + ], [cookie], "get: FPI on, w/ firstPartyDomain, FP cookie (set when FPI off)"); + + // getAll + for (let extra of [{}, {url}, {domain: firstPartyDomain}]) { + const prefix = `getAll(${JSON.stringify(extra)})`; + await browser.test.assertRejects( + browser.cookies.getAll({...extra}), + expectedError, + `${prefix}: FPI on, w/o firstPartyDomain, rejection`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: ""}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + ], cookies, `${prefix}: FPI on, w/ empty firstPartyDomain, non-FP cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: null}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + {name: "foo2", value: "bar2", firstPartyDomain}, + {name: "foo4", value: "bar4", firstPartyDomain}, + ], cookies, `${prefix}: FPI on, w/ null firstPartyDomain, all cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain: undefined}); + assertExpectedCookies([ + {name: "foo1", value: "bar1", firstPartyDomain: ""}, + {name: "foo2", value: "bar2", firstPartyDomain}, + {name: "foo4", value: "bar4", firstPartyDomain}, + ], cookies, `${prefix}: FPI on, w/ undefined firstPartyDomain, all cookies`); + cookies = await browser.cookies.getAll({...extra, firstPartyDomain}); + assertExpectedCookies([ + {name: "foo2", value: "bar2", firstPartyDomain}, + {name: "foo4", value: "bar4", firstPartyDomain}, + ], cookies, `${prefix}: FPI on, w/ firstPartyDomain, FP cookies`); + } + + // remove + await browser.test.assertRejects( + browser.cookies.remove({url, name: "foo3"}), + expectedError, + "remove: FPI on, w/o firstPartyDomain, rejection"); + cookie = await browser.cookies.remove({url, name: "foo4", firstPartyDomain}); + assertExpectedCookies([ + {url, name: "foo4", firstPartyDomain}, + ], [cookie], "remove: FPI on, w/ firstPartyDomain, FP cookie"); + cookie = await browser.cookies.remove({url, name: "foo2", firstPartyDomain}); + assertExpectedCookies([ + {url, name: "foo2", firstPartyDomain}, + ], [cookie], "remove: FPI on, w/ firstPartyDomain, FP cookie (set when FPI off)"); + + // Test if FP cookies set when FPI on can be accessed when FPI off. + await browser.cookies.set({url, name: "foo4", value: "bar4", firstPartyDomain}); + + browser.test.sendMessage("test_fpi_enabled"); + }; + + // Test FPI with a first party domain with invalid characters for + // the file system. + const test_fpi_with_invalid_characters = async () => { + let cookie; + + // Test setting a cookie with a first party domain with invalid characters + // for the file system. + cookie = await browser.cookies.set({url, name: "foo5", value: "bar5", + firstPartyDomain: firstPartyDomainInvalidChars}); + assertExpectedCookies([ + {name: "foo5", value: "bar5", firstPartyDomain: firstPartyDomainInvalidChars}, + ], [cookie], "set: FPI on, w/ firstPartyDomain with invalid characters, FP cookie"); + + // Test getting a cookie with a first party domain with invalid characters + // for the file system. + cookie = await browser.cookies.get({url, name: "foo5", + firstPartyDomain: firstPartyDomainInvalidChars}); + assertExpectedCookies([ + {name: "foo5", value: "bar5", firstPartyDomain: firstPartyDomainInvalidChars}, + ], [cookie], "get: FPI on, w/ firstPartyDomain with invalid characters, FP cookie"); + + // Test removing a cookie with a first party domain with invalid characters + // for the file system. + cookie = await browser.cookies.remove({url, name: "foo5", + firstPartyDomain: firstPartyDomainInvalidChars}); + assertExpectedCookies([ + {url, name: "foo5", firstPartyDomain: firstPartyDomainInvalidChars}, + ], [cookie], "remove: FPI on, w/ firstPartyDomain with invalid characters, FP cookie"); + + browser.test.sendMessage("test_fpi_with_invalid_characters"); + }; + + // Test when FPI is disabled again, accessing FP cookies set when FPI is enabled. + const test_fpd_cookies_on_fpi_disabled = async () => { + let cookie, cookies; + cookie = await browser.cookies.get({url, name: "foo4", firstPartyDomain}); + assertExpectedCookies([ + {name: "foo4", value: "bar4", firstPartyDomain}, + ], [cookie], "get: FPI off, w/ firstPartyDomain, FP cookie (set when FPI on)"); + cookie = await browser.cookies.remove({url, name: "foo4", firstPartyDomain}); + assertExpectedCookies([ + {url, name: "foo4", firstPartyDomain}, + ], [cookie], "remove: FPI off, w/ firstPartyDomain, FP cookie (set when FPI on)"); + + // Clean up. + await browser.cookies.remove({url, name: "foo1"}); + + cookies = await browser.cookies.getAll({firstPartyDomain: null}); + assertExpectedCookies([], cookies, "Test is finishing, all cookies removed"); + + browser.test.sendMessage("test_fpd_cookies_on_fpi_disabled"); + }; + + browser.test.onMessage.addListener((message) => { + switch (message) { + case "test_fpi_disabled": return test_fpi_disabled(); + case "test_fpi_enabled": return test_fpi_enabled(); + case "test_fpi_with_invalid_characters": return test_fpi_with_invalid_characters(); + case "test_fpd_cookies_on_fpi_disabled": return test_fpd_cookies_on_fpi_disabled(); + default: return browser.test.notifyFail("unknown-message"); + } + }); +} + +function enableFirstPartyIsolation() { + return SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.firstparty.isolate", true], + ], + }); +} + +function disableFirstPartyIsolation() { + return SpecialPowers.popPrefEnv(); +} + +add_task(async () => { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://ext-cookie-first-party.mochi.test/"], + }, + }); + await extension.startup(); + extension.sendMessage("test_fpi_disabled"); + await extension.awaitMessage("test_fpi_disabled"); + await enableFirstPartyIsolation(); + extension.sendMessage("test_fpi_enabled"); + await extension.awaitMessage("test_fpi_enabled"); + extension.sendMessage("test_fpi_with_invalid_characters"); + await extension.awaitMessage("test_fpi_with_invalid_characters"); + await disableFirstPartyIsolation(); + extension.sendMessage("test_fpd_cookies_on_fpi_disabled"); + await extension.awaitMessage("test_fpd_cookies_on_fpi_disabled"); + await extension.unload(); +}); +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html new file mode 100644 index 0000000000..a7c6931c06 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_incognito.html @@ -0,0 +1,112 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_cookies_incognito_not_allowed() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.allowPrivateBrowsingByDefault", false]], + }); + + let privateExtension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + let window = await browser.windows.create({incognito: true}); + browser.test.onMessage.addListener(async () => { + await browser.windows.remove(window.id); + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + manifest: { + permissions: ["cookies", "*://example.org/"], + }, + }); + await privateExtension.startup(); + await privateExtension.awaitMessage("ready"); + + async function background() { + const storeId = "firefox-private"; + const url = "http://example.org/"; + + // Getting the wrong storeId will fail, otherwise we should finish the test fine. + browser.cookies.onChanged.addListener(changeInfo => { + let {cookie} = changeInfo; + browser.test.assertTrue(cookie.storeId != storeId, "cookie store is correct"); + }); + + browser.test.onMessage.addListener(async () => { + let stores = await browser.cookies.getAllCookieStores(); + let store = stores.find(s => s.incognito); + browser.test.assertTrue(!store, "incognito cookie store should not be available"); + browser.test.notifyPass("cookies"); + }); + + await browser.test.assertRejects( + browser.cookies.set({url, name: "test", storeId}), + /Extension disallowed access/, + "API should reject setting cookie"); + await browser.test.assertRejects( + browser.cookies.get({url, name: "test", storeId}), + /Extension disallowed access/, + "API should reject getting cookie"); + await browser.test.assertRejects( + browser.cookies.getAll({url, storeId}), + /Extension disallowed access/, + "API should reject getting cookie"); + await browser.test.assertRejects( + browser.cookies.remove({url, name: "test", storeId}), + /Extension disallowed access/, + "API should reject getting cookie"); + await browser.test.assertRejects( + browser.cookies.getAll({url, storeId}), + /Extension disallowed access/, + "API should reject getting cookie"); + + browser.test.sendMessage("set-cookies"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://example.org/"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("set-cookies"); + + let chromeScript = SpecialPowers.loadChromeScript(() => { + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + + Services.cookies.add("example.org", "/", "public", `foo${Math.random()}`, + false, false, false, Number.MAX_SAFE_INTEGER, {}, + Ci.nsICookie.SAMESITE_NONE); + Services.cookies.add("example.org", "/", "private", `foo${Math.random()}`, + false, false, false, Number.MAX_SAFE_INTEGER, {privateBrowsingId: 1}, + Ci.nsICookie.SAMESITE_NONE); + }); + extension.sendMessage("test-cookie-store"); + await extension.awaitFinish("cookies"); + + await extension.unload(); + privateExtension.sendMessage("close"); + await privateExtension.awaitMessage("done"); + await privateExtension.unload(); + chromeScript.destroy(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html new file mode 100644 index 0000000000..0bd2852075 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_bad.html @@ -0,0 +1,115 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_cookies.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function init() { + // We need to trigger a cookie eviction in order to test our batch delete + // observer. + + // Set quotaPerHost to maxPerHost - 1, so there is only one cookie + // will be evicted everytime. + SpecialPowers.setIntPref("network.cookie.quotaPerHost", 2); + SpecialPowers.setIntPref("network.cookie.maxPerHost", 3); + SimpleTest.registerCleanupFunction(() => { + SpecialPowers.clearUserPref("network.cookie.quotaPerHost"); + SpecialPowers.clearUserPref("network.cookie.maxPerHost"); + }); +}); + +add_task(async function test_bad_cookie_permissions() { + info("Test non-matching, non-secure domain with non-secure cookie"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "http://example.net/", + domain: "example.net", + secure: false, + shouldPass: false, + shouldWrite: false, + }); + + info("Test non-matching, secure domain with non-secure cookie"); + await testCookies({ + permissions: ["https://example.com/", "cookies"], + url: "https://example.net/", + domain: "example.net", + secure: false, + shouldPass: false, + shouldWrite: false, + }); + + info("Test non-matching, secure domain with secure cookie"); + await testCookies({ + permissions: ["https://example.com/", "cookies"], + url: "https://example.net/", + domain: "example.net", + secure: false, + shouldPass: false, + shouldWrite: false, + }); + + info("Test matching subdomain with superdomain privileges, secure cookie (http)"); + await testCookies({ + permissions: ["http://foo.bar.example.com/", "cookies"], + url: "http://foo.bar.example.com/", + domain: ".example.com", + secure: true, + shouldPass: false, + shouldWrite: true, + }); + + info("Test matching, non-secure domain with secure cookie"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "http://example.com/", + domain: "example.com", + secure: true, + shouldPass: false, + shouldWrite: true, + }); + + info("Test matching, non-secure host, secure URL"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "https://example.com/", + domain: "example.com", + secure: true, + shouldPass: false, + shouldWrite: false, + }); + + info("Test non-matching domain"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "http://example.com/", + domain: "example.net", + secure: false, + shouldPass: false, + shouldWrite: false, + }); + + info("Test invalid scheme"); + await testCookies({ + permissions: ["ftp://example.com/", "cookies"], + url: "ftp://example.com/", + domain: "example.com", + secure: false, + shouldPass: false, + shouldWrite: false, + }); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html new file mode 100644 index 0000000000..bd76f2b9c0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_cookies_permissions_good.html @@ -0,0 +1,89 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_cookies.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function init() { + // We need to trigger a cookie eviction in order to test our batch delete + // observer. + + // Set quotaPerHost to maxPerHost - 1, so there is only one cookie + // will be evicted everytime. + SpecialPowers.setIntPref("network.cookie.quotaPerHost", 2); + SpecialPowers.setIntPref("network.cookie.maxPerHost", 3); + SimpleTest.registerCleanupFunction(() => { + SpecialPowers.clearUserPref("network.cookie.quotaPerHost"); + SpecialPowers.clearUserPref("network.cookie.maxPerHost"); + }); +}); + +add_task(async function test_good_cookie_permissions() { + info("Test matching, non-secure domain with non-secure cookie"); + await testCookies({ + permissions: ["http://example.com/", "cookies"], + url: "http://example.com/", + domain: "example.com", + secure: false, + shouldPass: true, + }); + + info("Test matching, secure domain with non-secure cookie"); + await testCookies({ + permissions: ["https://example.com/", "cookies"], + url: "https://example.com/", + domain: "example.com", + secure: false, + shouldPass: true, + }); + + info("Test matching, secure domain with secure cookie"); + await testCookies({ + permissions: ["https://example.com/", "cookies"], + url: "https://example.com/", + domain: "example.com", + secure: true, + shouldPass: true, + }); + + info("Test matching subdomain with superdomain privileges, secure cookie (https)"); + await testCookies({ + permissions: ["https://foo.bar.example.com/", "cookies"], + url: "https://foo.bar.example.com/", + domain: ".example.com", + secure: true, + shouldPass: true, + }); + + info("Test matching subdomain with superdomain privileges, non-secure cookie (https)"); + await testCookies({ + permissions: ["https://foo.bar.example.com/", "cookies"], + url: "https://foo.bar.example.com/", + domain: ".example.com", + secure: false, + shouldPass: true, + }); + + info("Test matching subdomain with superdomain privileges, non-secure cookie (http)"); + await testCookies({ + permissions: ["http://foo.bar.example.com/", "cookies"], + url: "http://foo.bar.example.com/", + domain: ".example.com", + secure: false, + shouldPass: true, + }); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html new file mode 100644 index 0000000000..ea163db0de --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_downloads_download.html @@ -0,0 +1,90 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Downloads Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function background() { + const url = "http://mochi.test:8888/tests/mobile/android/components/extensions/test/mochitest/context_tabs_onUpdated_page.html"; + + browser.test.assertThrows( + () => browser.downloads.download(), + /Incorrect argument types for downloads.download/, + "Should fail without options" + ); + + browser.test.assertThrows( + () => browser.downloads.download({url: "invalid url"}), + /invalid url is not a valid URL/, + "Should fail on invalid URL" + ); + + browser.test.assertThrows( + () => browser.downloads.download({}), + /Property "url" is required/, + "Should fail with no URL" + ); + + browser.test.assertThrows( + () => browser.downloads.download({url, method: "DELETE"}), + /Invalid enumeration value "DELETE"/, + "Should fail with invalid method" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, headers: [{name: "Host", value: "Banana"}]}), + /Forbidden request header name/, + "Should fail with a forbidden header" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, filename: "/tmp/file.gif"}), + /filename must not be an absolute path/, + "Should fail with an absolute file path" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, filename: ""}), + /filename must not be empty/, + "Should fail with an empty file path" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, filename: "file."}), + /filename must not contain illegal characters/, + "Should fail with a dot in the filename" + ); + + await browser.test.assertRejects( + browser.downloads.download({url, filename: "../file.gif"}), + /filename must not contain back-references/, + "Should fail with a file path that contains back-references" + ); + + browser.test.notifyPass("download.done"); +} + +add_task(async function test_invalid_download_parameters() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: {permissions: ["downloads"]}, + background, + }); + await extension.startup(); + + await extension.awaitFinish("download.done"); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html b/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html new file mode 100644 index 0000000000..d6702da4d3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_embeddedimg_iframe_frameAncestors.html @@ -0,0 +1,94 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test checking webRequest.onBeforeRequest details object</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let expected = { + "file_contains_iframe.html": { + type: "main_frame", + frameAncestor_length: 0, + }, + "file_contains_img.html": { + type: "sub_frame", + frameAncestor_length: 1, + }, + "file_image_good.png": { + type: "image", + frameAncestor_length: 1, + } +}; + +function checkDetails(details) { + let url = new URL(details.url); + let filename = url.pathname.split("/").pop(); + ok(expected.hasOwnProperty(filename), `Should be expecting a request for ${filename}`); + let expect = expected[filename]; + is(expect.type, details.type, `${details.type} type matches`); + is(expect.frameAncestor_length, details.frameAncestors.length, "incorrect frameAncestors length"); + if (filename == "file_contains_img.html") { + is(details.frameAncestors[0].frameId, details.parentFrameId, + "frameAncestors[0] should match parentFrameId"); + expected["file_image_good.png"].frameId = details.frameId; + } else if (filename == "file_image_good.png") { + is(details.frameAncestors[0].frameId, details.parentFrameId, + "frameAncestors[0] should match parentFrameId"); + is(details.frameId, expect.frameId, + "frameId for image and iframe should match"); + } +} + +add_task(async () => { + // Clear the image cache, since it gets in the way otherwise. + let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools); + let cache = imgTools.getImgCacheForDocument(document); + cache.clearCache(false); + await SpecialPowers.spawnChrome([], async () => { + Services.cache2.clear(); + }); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("onBeforeRequest", details); + }, + { + urls: [ + "http://example.org/*/file_contains_img.html", + "http://mochi.test/*/file_contains_iframe.html", + "*://*/*.png", + ], + } + ); + }, + }); + + await extension.startup(); + const FILE_URL = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html"; + let win = window.open(FILE_URL); + await new Promise(resolve => win.addEventListener("load", () => resolve(), {once: true})); + + for (let i = 0; i < Object.keys(expected).length; i++) { + checkDetails(await extension.awaitMessage("onBeforeRequest")); + } + + win.close(); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html new file mode 100644 index 0000000000..bdf300ec50 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_exclude_include_globs.html @@ -0,0 +1,91 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_contentscript() { + function background() { + browser.runtime.onMessage.addListener(([script], sender) => { + browser.test.sendMessage("run", {script}); + browser.test.sendMessage("run-" + script); + }); + browser.test.sendMessage("running"); + } + + function contentScriptAll() { + browser.runtime.sendMessage(["all"]); + } + function contentScriptIncludesTest1() { + browser.runtime.sendMessage(["includes-test1"]); + } + function contentScriptExcludesTest1() { + browser.runtime.sendMessage(["excludes-test1"]); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://example.org/", "http://*.example.org/"], + "exclude_globs": [], + "include_globs": ["*"], + "js": ["content_script_all.js"], + }, + { + "matches": ["http://example.org/", "http://*.example.org/"], + "include_globs": ["*test1*"], + "js": ["content_script_includes_test1.js"], + }, + { + "matches": ["http://example.org/", "http://*.example.org/"], + "exclude_globs": ["*test1*"], + "js": ["content_script_excludes_test1.js"], + }, + ], + }, + background, + + files: { + "content_script_all.js": contentScriptAll, + "content_script_includes_test1.js": contentScriptIncludesTest1, + "content_script_excludes_test1.js": contentScriptExcludesTest1, + }, + + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let ran = 0; + extension.onMessage("run", ({script}) => { + ran++; + }); + + await Promise.all([extension.startup(), extension.awaitMessage("running")]); + info("extension loaded"); + + let win = window.open("http://example.org/"); + await Promise.all([extension.awaitMessage("run-all"), extension.awaitMessage("run-excludes-test1")]); + win.close(); + is(ran, 2); + + win = window.open("http://test1.example.org/"); + await Promise.all([extension.awaitMessage("run-all"), extension.awaitMessage("run-includes-test1")]); + win.close(); + is(ran, 4); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html new file mode 100644 index 0000000000..ba91989d9c --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_external_messaging.html @@ -0,0 +1,110 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension external messaging</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function backgroundScript(id, otherId) { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.fail(`Got unexpected message: ${uneval(msg)} ${uneval(sender)}`); + }); + + browser.runtime.onConnect.addListener(port => { + browser.test.fail(`Got unexpected connection: ${uneval(port.sender)}`); + }); + + browser.runtime.onMessageExternal.addListener((msg, sender) => { + browser.test.assertEq(otherId, sender.id, `${id}: Got expected external sender ID`); + browser.test.assertEq(`helo-${id}`, msg, "Got expected message"); + + browser.test.sendMessage("onMessage-done"); + + return Promise.resolve(`ehlo-${otherId}`); + }); + + browser.runtime.onConnectExternal.addListener(port => { + browser.test.assertEq(otherId, port.sender.id, `${id}: Got expected external connecter ID`); + + port.onMessage.addListener(msg => { + browser.test.assertEq(`helo-${id}`, msg, "Got expected port message"); + + port.postMessage(`ehlo-${otherId}`); + + browser.test.sendMessage("onConnect-done"); + }); + }); + + browser.test.onMessage.addListener(msg => { + if (msg === "go") { + browser.runtime.sendMessage(otherId, `helo-${otherId}`).then(result => { + browser.test.assertEq(`ehlo-${id}`, result, "Got expected reply"); + browser.test.sendMessage("sendMessage-done"); + }); + + let port = browser.runtime.connect(otherId); + port.postMessage(`helo-${otherId}`); + + port.onMessage.addListener(msg => { + port.disconnect(); + + browser.test.assertEq(msg, `ehlo-${id}`, "Got expected port reply"); + browser.test.sendMessage("connect-done"); + }); + } + }); +} + +function makeExtension(id, otherId) { + let args = `${JSON.stringify(id)}, ${JSON.stringify(otherId)}`; + + let extensionData = { + background: `(${backgroundScript})(${args})`, + manifest: { + "applications": {"gecko": {id}}, + }, + }; + + return ExtensionTestUtils.loadExtension(extensionData); +} + +add_task(async function test_contentscript() { + const ID1 = "foo-message@mochitest.mozilla.org"; + const ID2 = "bar-message@mochitest.mozilla.org"; + + let extension1 = makeExtension(ID1, ID2); + let extension2 = makeExtension(ID2, ID1); + + await Promise.all([extension1.startup(), extension2.startup()]); + + extension1.sendMessage("go"); + extension2.sendMessage("go"); + + await Promise.all([ + extension1.awaitMessage("sendMessage-done"), + extension2.awaitMessage("sendMessage-done"), + + extension1.awaitMessage("onMessage-done"), + extension2.awaitMessage("onMessage-done"), + + extension1.awaitMessage("connect-done"), + extension2.awaitMessage("connect-done"), + + extension1.awaitMessage("onConnect-done"), + extension2.awaitMessage("onConnect-done"), + ]); + + await extension1.unload(); + await extension2.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_generate.html b/toolkit/components/extensions/test/mochitest/test_ext_generate.html new file mode 100644 index 0000000000..ba88d16ca3 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_generate.html @@ -0,0 +1,48 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for generating WebExtensions</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + browser.test.log("running background script"); + + browser.test.onMessage.addListener((x, y) => { + browser.test.assertEq(x, 10, "x is 10"); + browser.test.assertEq(y, 20, "y is 20"); + + browser.test.notifyPass("background test passed"); + }); + + browser.test.sendMessage("running", 1); +} + +let extensionData = { + background, +}; + +add_task(async function test_background() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + info("load complete"); + let [, x] = await Promise.all([extension.startup(), extension.awaitMessage("running")]); + is(x, 1, "got correct value from extension"); + info("startup complete"); + extension.sendMessage(10, 20); + await extension.awaitFinish(); + info("test complete"); + await extension.unload(); + info("extension unloaded successfully"); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html new file mode 100644 index 0000000000..9f326372bb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_geolocation.html @@ -0,0 +1,86 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +add_task(async function test_geolocation_nopermission() { + let GEO_URL = "http://mochi.test:8888/tests/dom/tests/mochitest/geolocation/network_geolocation.sjs"; + await SpecialPowers.pushPrefEnv({"set": [["geo.provider.network.url", GEO_URL]]}); +}); + +add_task(async function test_geolocation() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "geolocation", + ], + }, + background() { + navigator.geolocation.getCurrentPosition(() => { + browser.test.notifyPass("success geolocation call"); + }, (error) => { + browser.test.notifyFail(`geolocation call ${error}`); + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_geolocation_nopermission() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + navigator.geolocation.getCurrentPosition(() => { + browser.test.notifyFail("success geolocation call"); + }, (error) => { + browser.test.notifyPass(`geolocation call ${error}`); + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_geolocation_prompt() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.create({url: "tab.html"}); + }, + files: { + "tab.html": `<html><head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head></html>`, + "tab.js": () => { + navigator.geolocation.getCurrentPosition(() => { + browser.test.notifyPass("success geolocation call"); + }, (error) => { + browser.test.notifyFail(`geolocation call ${error}`); + }); + }, + }, + }); + + // Bypass the actual prompt, but the prompt result is to allow access. + await SpecialPowers.pushPrefEnv({"set": [["geo.prompt.testing", true], ["geo.prompt.testing.allow", true]]}); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); +</script> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_identity.html b/toolkit/components/extensions/test/mochitest/test_ext_identity.html new file mode 100644 index 0000000000..c40578cd40 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_identity.html @@ -0,0 +1,390 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for WebExtension Identity</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.webextensions.identity.redirectDomain", "example.com"], + // Disable the network cache first-party partition during this + // test (TODO: look more closely to how that is affecting the intermittency + // of this test on MacOS, see Bug 1626482). + ["privacy.partition.network_state", false], + ], + }); +}); + +add_task(async function test_noPermission() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.assertEq( + undefined, + browser.identity, + "No identity api without permission" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_getRedirectURL() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["identity", "https://example.com/"], + }, + async background() { + let redirect_base = + "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/"; + await browser.test.assertEq( + redirect_base, + browser.identity.getRedirectURL(), + "redirect url ok" + ); + await browser.test.assertEq( + redirect_base, + browser.identity.getRedirectURL(""), + "redirect url ok" + ); + await browser.test.assertEq( + redirect_base + "foobar", + browser.identity.getRedirectURL("foobar"), + "redirect url ok" + ); + await browser.test.assertEq( + redirect_base + "callback", + browser.identity.getRedirectURL("/callback"), + "redirect url ok" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_badAuthURI() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["identity", "https://example.com/"], + }, + async background() { + for (let url of [ + "foobar", + "about:addons", + "about:blank", + "ftp://example.com/test", + ]) { + await browser.test.assertThrows( + () => { + browser.identity.launchWebAuthFlow({ interactive: true, url }); + }, + /Type error for parameter details/, + "details.url is invalid" + ); + } + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_badRequestURI() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["identity", "https://example.com/"], + }, + async background() { + let base_uri = + "https://example.com/tests/toolkit/components/extensions/test/mochitest/"; + let url = `${base_uri}?redirect_uri=badrobot}`; + await browser.test.assertRejects( + browser.identity.launchWebAuthFlow({ interactive: true, url }), + "redirect_uri is invalid", + "invalid redirect url" + ); + url = `${base_uri}?redirect_uri=https://somesite.com`; + await browser.test.assertRejects( + browser.identity.launchWebAuthFlow({ interactive: true, url }), + "redirect_uri not allowed", + "invalid redirect url" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function background_launchWebAuthFlow_requires_interaction() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["identity", "https://example.com/"], + }, + async background() { + let base_uri = + "https://example.com/tests/toolkit/components/extensions/test/mochitest/"; + let url = `${base_uri}?redirect_uri=${browser.identity.getRedirectURL( + "redirect" + )}`; + await browser.test.assertRejects( + browser.identity.launchWebAuthFlow({ interactive: false, url }), + "Requires user interaction", + "Rejects on required user interaction" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +function background_launchWebAuthFlow({ + interactive = false, + path = "redirect_auto.sjs", + params = {}, + redirect = true, + useRedirectUri = true, +} = {}) { + let uri_path = useRedirectUri ? "identity_cb" : ""; + let expected_redirect = `https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/${uri_path}`; + let base_uri = + "https://example.com/tests/toolkit/components/extensions/test/mochitest/"; + let redirect_uri = browser.identity.getRedirectURL( + useRedirectUri ? uri_path : undefined + ); + browser.test.assertEq( + expected_redirect, + redirect_uri, + "expected redirect uri matches hash" + ); + let url = `${base_uri}${path}`; + if (useRedirectUri) { + params.redirect_uri = redirect_uri; + } else { + // We kind of fake it with the redirect url that would normally be configured + // in the oauth service. This does still test that the identity service falls back + // to the extensions redirect url. + params.default_redirect = expected_redirect; + } + if (!redirect) { + params.no_redirect = 1; + } + let query = []; + for (let [param, value] of Object.entries(params)) { + query.push(`${param}=${encodeURIComponent(value)}`); + } + url = `${url}?${query.join("&")}`; + + // Ensure we do not start the actual request for the redirect url. In the case + // of a 303 POST redirect we are getting a request started. + let watchRedirectRequest = () => {}; + if (params.post !== 303) { + watchRedirectRequest = details => { + if (details.url.startsWith(expected_redirect)) { + browser.test.fail(`onBeforeRequest called for redirect url: ${JSON.stringify(details)}`); + } + }; + + browser.webRequest.onBeforeRequest.addListener( + watchRedirectRequest, + { + urls: [ + "https://35b64b676900f491c00e7f618d43f7040e88422e.example.com/*", + ], + } + ); + } + + browser.identity + .launchWebAuthFlow({ interactive, url }) + .then(redirectURL => { + browser.test.assertTrue( + redirectURL.startsWith(redirect_uri), + `correct redirect url ${redirectURL}` + ); + if (redirect) { + let url = new URL(redirectURL); + browser.test.assertEq( + "here ya go", + url.searchParams.get("access_token"), + "Handled auto redirection" + ); + } + }) + .catch(error => { + if (redirect) { + browser.test.fail(error.message); + } else { + browser.test.assertEq( + "Requires user interaction", + error.message, + "Auth page loaded, interaction required." + ); + } + }).then(() => { + browser.webRequest.onBeforeRequest.removeListener(watchRedirectRequest); + browser.test.sendMessage("done"); + }); +} + +// Tests the situation where the oauth provider has already granted access and +// simply redirects the oauth client to provide the access key or code. +add_task(async function test_autoRedirect() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})()`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_autoRedirect_noRedirectURI() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})({useRedirectUri: false})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Tests the situation where the oauth provider has not granted access and interactive=false +add_task(async function test_noRedirect() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})({redirect: false})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Tests the situation where the oauth provider must show a window where +// presumably the user interacts, then the redirect occurs and access key or +// code is provided. We bypass any real interaction, but want the window to +// open and result in a redirect. +add_task(async function test_interaction() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})({interactive: true, path: "oauth.html"})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Tests the situation where the oauth provider redirects with a 303. +add_task(async function test_auto303Redirect() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["webRequest", "identity", "https://*.example.com/*"], + }, + background: `(${background_launchWebAuthFlow})({interactive: true, path: "oauth.html", params: {post: 303, server_uri: "redirect_auto.sjs"}})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_loopbackRedirectURI() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "identity@mozilla.org", + }, + }, + permissions: ["identity"], + }, + async background() { + let redirectURL = "http://127.0.0.1/mozoauth2/35b64b676900f491c00e7f618d43f7040e88422e"; + let actualRedirect = await browser.identity.launchWebAuthFlow({ + interactive: true, + url: `https://example.com/tests/toolkit/components/extensions/test/mochitest/oauth.html?redirect_uri=${encodeURIComponent(redirectURL)}` + }).catch(error => { + browser.test.fail(error.message) + }); + browser.test.assertTrue( + actualRedirect.startsWith(redirectURL), + "Expected redirect url to be loopback address" + ) + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_idle.html b/toolkit/components/extensions/test/mochitest/test_ext_idle.html new file mode 100644 index 0000000000..381687ee38 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_idle.html @@ -0,0 +1,68 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function testWithRealIdleService() { + function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let detectionInterval = args[0]; + if (msg == "addListener") { + let status = await browser.idle.queryState(detectionInterval); + browser.test.assertEq("active", status, "Idle status is active"); + browser.idle.setDetectionInterval(detectionInterval); + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq("idle", newState, "listener fired with the expected state"); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } else if (msg == "checkState") { + let status = await browser.idle.queryState(detectionInterval); + browser.test.assertEq("idle", status, "Idle status is idle"); + browser.test.notifyPass("idle"); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + + let chromeScript = loadChromeScript(() => { + const {sendAsyncMessage} = this; + const idleService = Cc["@mozilla.org/widget/useridleservice;1"].getService(Ci.nsIUserIdleService); + let idleTime = idleService.idleTime; + sendAsyncMessage("detectionInterval", Math.max(Math.ceil(idleTime / 1000) + 10, 15)); + }); + let detectionInterval = await chromeScript.promiseOneMessage("detectionInterval"); + chromeScript.destroy(); + + info(`Setting interval to ${detectionInterval}`); + extension.sendMessage("addListener", detectionInterval); + await extension.awaitMessage("listenerAdded"); + info("Listener added"); + await extension.awaitMessage("listenerFired"); + info("Listener fired"); + extension.sendMessage("checkState", detectionInterval); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html new file mode 100644 index 0000000000..5b36902581 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_inIncognitoContext_window.html @@ -0,0 +1,49 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_in_incognito_context_true() { + function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(true, msg, "inIncognitoContext is true"); + browser.test.notifyPass("inIncognitoContext"); + }); + + browser.windows.create({url: browser.runtime.getURL("/tab.html"), incognito: true}); + } + + function tabScript() { + browser.runtime.sendMessage(browser.extension.inIncognitoContext); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "tab.js": tabScript, + "tab.html": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head></html>`, + }, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitFinish("inIncognitoContext"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html new file mode 100644 index 0000000000..cc161f735f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_listener_proxies.html @@ -0,0 +1,62 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_listener_proxies() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + "permissions": ["storage"], + }, + + async background() { + // Test that adding multiple listeners for the same event works as + // expected. + + let awaitChanged = () => new Promise(resolve => { + browser.storage.onChanged.addListener(function listener() { + browser.storage.onChanged.removeListener(listener); + resolve(); + }); + }); + + let promises = [ + awaitChanged(), + awaitChanged(), + ]; + + function removedListener() {} + browser.storage.onChanged.addListener(removedListener); + browser.storage.onChanged.removeListener(removedListener); + + promises.push(awaitChanged(), awaitChanged()); + + browser.storage.local.set({foo: "bar"}); + + await Promise.all(promises); + + browser.test.notifyPass("onchanged-listeners"); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("onchanged-listeners"); + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html new file mode 100644 index 0000000000..4561ef1a28 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_new_tab_processType.html @@ -0,0 +1,152 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for opening links in new tabs from extension frames</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function promiseObserved(topic, check) { + return new Promise(resolve => { + let obs = SpecialPowers.Services.obs; + + function observer(subject, topic, data) { + subject = SpecialPowers.wrap(subject); + if (check(subject, data)) { + obs.removeObserver(observer, topic); + resolve({subject, data}); + } + } + obs.addObserver(observer, topic); + }); +} + +add_task(async function test_target_blank_link_no_opener_from_privileged() { + const linkURL = "http://example.com/"; + + function extension_tab() { + document.getElementById("link").click(); + } + + function content_script() { + browser.runtime.sendMessage("content_page_loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "target_blank_link@tests.mozilla.org" } }, + content_scripts: [{ + js: ["content_script.js"], + matches: ["http://example.com/*"], + run_at: "document_idle", + }], + permissions: ["tabs"], + }, + files: { + "page.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></html> + <body> + <a href="${linkURL}" target="_blank" id="link">link</a> + <script src="extension_tab.js"><\/script> + </body> + </html>`, + "extension_tab.js": extension_tab, + "content_script.js": content_script, + }, + background() { + let pageTab; + browser.runtime.onMessage.addListener((msg, sender) => { + if (sender.tab) { + browser.test.sendMessage(msg, sender.tab.url); + browser.tabs.remove(sender.tab.id); + browser.tabs.remove(pageTab.id); + } + }); + pageTab = browser.tabs.create({ url: browser.runtime.getURL("page.html") }); + }, + }); + + await extension.startup(); + + // Make sure page is loaded correctly + const url = await extension.awaitMessage("content_page_loaded"); + is(url, linkURL, "Page URL should match"); + + await extension.unload(); +}); + +add_task(async function test_target_blank_link() { + const linkURL = "http://mochi.test:8888/tests/toolkit/"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_security_policy: "script-src 'self' 'unsafe-eval'; object-src 'self';", + + web_accessible_resources: ["iframe.html"], + }, + files: { + "iframe.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></html> + <body> + <a href="${linkURL}" target="_blank" id="link" rel="opener">link</a> + </body> + </html>`, + }, + background() { + browser.test.sendMessage("frame_url", browser.runtime.getURL("iframe.html")); + }, + }); + + await extension.startup(); + + let url = await extension.awaitMessage("frame_url"); + + let iframe = document.createElement("iframe"); + iframe.src = url; + document.body.appendChild(iframe); + await new Promise(resolve => iframe.addEventListener("load", () => setTimeout(resolve, 0), {once: true})); + + let win = SpecialPowers.wrap(iframe).contentWindow; + + { + // Flush layout so that synthesizeMouseAtCenter on a cross-origin iframe + // works as expected. + document.body.getBoundingClientRect(); + + let promise = promiseObserved("document-element-inserted", doc => doc.documentURI === linkURL); + + await SpecialPowers.spawn(iframe, [], async () => { + this.content.document.getElementById("link").click(); + }); + + let {subject: doc} = await promise; + info("Link opened"); + doc.defaultView.close(); + info("Window closed"); + } + + { + let promise = promiseObserved("document-element-inserted", doc => doc.documentURI === linkURL); + + let res = win.eval(`window.open("${linkURL}")`); + let {subject: doc} = await promise; + is(SpecialPowers.unwrap(res), SpecialPowers.unwrap(doc.defaultView), "window.open worked as expected"); + + doc.defaultView.close(); + } + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_notifications.html b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html new file mode 100644 index 0000000000..7a91320373 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_notifications.html @@ -0,0 +1,340 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for notifications</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_notifications.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// A 1x1 PNG image. +// Source: https://commons.wikimedia.org/wiki/File:1x1.png (Public Domain) +let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer; + +add_task(async function setup_mock_alert_service() { + await MockAlertsService.register(); +}); + +add_task(async function test_notification() { + async function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + }; + + let id = await browser.notifications.create(opts); + + browser.test.sendMessage("running", id); + browser.test.notifyPass("background test passed"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + await extension.startup(); + let x = await extension.awaitMessage("running"); + is(x, "0", "got correct id from notifications.create"); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_notification_events() { + async function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + }; + + let createdId = "98"; + + // Test an ignored listener. + browser.notifications.onButtonClicked.addListener(function() {}); + + // We cannot test onClicked listener without a mock + // but we can attempt to add a listener. + browser.notifications.onClicked.addListener(async function(id) { + browser.test.assertEq(createdId, id, "onClicked has the expected ID"); + browser.test.sendMessage("notification-event", "clicked"); + }); + + browser.notifications.onShown.addListener(async function listener(id) { + browser.test.assertEq(createdId, id, "onShown has the expected ID"); + browser.test.sendMessage("notification-event", "shown"); + }); + + browser.test.onMessage.addListener(async function(msg, expectedCount) { + if (msg === "create-again") { + let newId = await browser.notifications.create(createdId, opts); + browser.test.assertEq(createdId, newId, "create returned the expected id."); + browser.test.sendMessage("notification-created-twice"); + } else if (msg === "check-count") { + let notifications = await browser.notifications.getAll(); + let ids = Object.keys(notifications); + browser.test.assertEq(expectedCount, ids.length, `getAll() = ${ids}`); + browser.test.sendMessage("check-count-result"); + } + }); + + // Test onClosed listener. + browser.notifications.onClosed.addListener(function listener(id) { + browser.test.assertEq(createdId, id, "onClosed received the expected id."); + browser.test.sendMessage("notification-event", "closed"); + }); + + await browser.notifications.create(createdId, opts); + + browser.test.sendMessage("notification-created-once"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + + await extension.startup(); + + async function waitForNotificationEvent(name) { + info(`Waiting for notification event: ${name}`); + is(name, await extension.awaitMessage("notification-event"), + "Expected notification event"); + } + async function checkNotificationCount(expectedCount) { + extension.sendMessage("check-count", expectedCount); + await extension.awaitMessage("check-count-result"); + } + + await extension.awaitMessage("notification-created-once"); + await waitForNotificationEvent("shown"); + await checkNotificationCount(1); + + // On most platforms, clicking the notification closes it. + // But on macOS, the notification can repeatedly be clicked without closing. + await MockAlertsService.clickNotificationsWithoutClose(); + await waitForNotificationEvent("clicked"); + await checkNotificationCount(1); + await MockAlertsService.clickNotificationsWithoutClose(); + await waitForNotificationEvent("clicked"); + await checkNotificationCount(1); + await MockAlertsService.clickNotifications(); + await waitForNotificationEvent("clicked"); + await waitForNotificationEvent("closed"); + await checkNotificationCount(0); + + extension.sendMessage("create-again"); + await extension.awaitMessage("notification-created-twice"); + await waitForNotificationEvent("shown"); + await checkNotificationCount(1); + + await MockAlertsService.closeNotifications(); + await waitForNotificationEvent("closed"); + await checkNotificationCount(0); + + await extension.unload(); +}); + +add_task(async function test_notification_clear() { + function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + }; + + let createdId = "99"; + + browser.notifications.onShown.addListener(async id => { + browser.test.assertEq(createdId, id, "onShown received the expected id."); + let wasCleared = await browser.notifications.clear(id); + browser.test.assertTrue(wasCleared, "notifications.clear returned true."); + }); + + browser.notifications.onClosed.addListener(id => { + browser.test.assertEq(createdId, id, "onClosed received the expected id."); + browser.test.notifyPass("background test passed"); + }); + + browser.notifications.create(createdId, opts); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_notifications_empty_getAll() { + async function background() { + let notifications = await browser.notifications.getAll(); + + browser.test.assertEq("object", typeof notifications, "getAll() returned an object"); + browser.test.assertEq(0, Object.keys(notifications).length, "the object has no properties"); + browser.test.notifyPass("getAll empty"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + await extension.startup(); + await extension.awaitFinish("getAll empty"); + await extension.unload(); +}); + +add_task(async function test_notifications_populated_getAll() { + async function background() { + let opts = { + type: "basic", + iconUrl: "a.png", + title: "Testing Notification", + message: "Carry on", + }; + + await browser.notifications.create("p1", opts); + await browser.notifications.create("p2", opts); + let notifications = await browser.notifications.getAll(); + + browser.test.assertEq("object", typeof notifications, "getAll() returned an object"); + browser.test.assertEq(2, Object.keys(notifications).length, "the object has 2 properties"); + + for (let notificationId of ["p1", "p2"]) { + for (let key of Object.keys(opts)) { + browser.test.assertEq( + opts[key], + notifications[notificationId][key], + `the notification has the expected value for option: ${key}` + ); + } + } + + browser.test.notifyPass("getAll populated"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + files: { + "a.png": IMAGE_ARRAYBUFFER, + }, + }); + await extension.startup(); + await extension.awaitFinish("getAll populated"); + await extension.unload(); +}); + +add_task(async function test_buttons_unsupported() { + function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + buttons: [{title: "Button title"}], + }; + + let exception = {}; + try { + browser.notifications.create(opts); + } catch (e) { + exception = e; + } + + browser.test.assertTrue( + String(exception).includes('Property "buttons" is unsupported by Firefox'), + "notifications.create with buttons option threw an expected exception" + ); + browser.test.notifyPass("buttons-unsupported"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + }); + await extension.startup(); + await extension.awaitFinish("buttons-unsupported"); + await extension.unload(); +}); + +add_task(async function test_notifications_different_contexts() { + async function background() { + let opts = { + type: "basic", + title: "Testing Notification", + message: "Carry on", + }; + + let id = await browser.notifications.create(opts); + + browser.runtime.onMessage.addListener(async (message, sender) => { + await browser.tabs.remove(sender.tab.id); + + // We should be able to clear the notification after creating and + // destroying the tab.html page. + let wasCleared = await browser.notifications.clear(id); + browser.test.assertTrue(wasCleared, "The notification was cleared."); + browser.test.notifyPass("notifications"); + }); + + browser.tabs.create({url: browser.runtime.getURL("/tab.html")}); + } + + async function tabScript() { + // We should be able to see the notification created in the background page + // in this page. + let notifications = await browser.notifications.getAll(); + browser.test.assertEq(1, Object.keys(notifications).length, + "One notification found."); + browser.runtime.sendMessage("continue-test"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + background, + files: { + "tab.js": tabScript, + "tab.html": `<!DOCTYPE html><html><head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head></html>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("notifications"); + await extension.unload(); +}); + +add_task(async function teardown_mock_alert_service() { + await MockAlertsService.unregister(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html new file mode 100644 index 0000000000..10305c8ac0 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_protocolHandlers.html @@ -0,0 +1,394 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Test for protocol handlers</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ +/* global addMessageListener, sendAsyncMessage */ + +function protocolChromeScript() { + addMessageListener("setup", () => { + let data = {}; + const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo"); + data.preferredAction = protoInfo.preferredAction === protoInfo.useHelperApp; + + let handlers = protoInfo.possibleApplicationHandlers; + data.handlers = handlers.length; + + let handler = handlers.queryElementAt(0, Ci.nsIHandlerApp); + data.isWebHandler = handler instanceof Ci.nsIWebHandlerApp; + data.uriTemplate = handler.uriTemplate; + + // ext+ protocols should be set as default when there is only one + data.preferredApplicationHandler = protoInfo.preferredApplicationHandler == handler; + data.alwaysAskBeforeHandling = protoInfo.alwaysAskBeforeHandling; + const handlerSvc = Cc["@mozilla.org/uriloader/handler-service;1"] + .getService(Ci.nsIHandlerService); + handlerSvc.store(protoInfo); + + sendAsyncMessage("handlerData", data); + }); +} + +add_task(async function test_protocolHandler() { + await SpecialPowers.pushPrefEnv({set: [ + ["extensions.allowPrivateBrowsingByDefault", false], + // Disabling the external protocol permission prompt. We don't need it + // for this test. + ["security.external_protocol_requires_permission", false], + ]}); + let extensionData = { + manifest: { + "protocol_handlers": [ + { + "protocol": "ext+foo", + "name": "a foo protocol handler", + "uriTemplate": "foo.html?val=%s", + }, + ], + }, + + background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "open") { + let tab = await browser.tabs.create({url: arg}); + browser.test.sendMessage("opened", tab.id); + } else if (msg == "close") { + await browser.tabs.remove(arg); + browser.test.sendMessage("closed"); + } + }); + browser.test.sendMessage("test-url", browser.runtime.getURL("foo.html")); + }, + + files: { + "foo.js": function() { + browser.test.sendMessage("test-query", location.search); + }, + "foo.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="foo.js"><\/script> + </head> + </html>`, + }, + }; + + let pb_extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "open") { + let win = await browser.windows.create({ url: arg, incognito: true }); + browser.test.sendMessage("opened", { windowId: win.id, tabId: win.tabs[0].id }); + } else if(msg == "nav") { + await browser.tabs.update(arg.tabId, { url: arg.url }) + browser.test.sendMessage("navigated"); + } else if (msg == "close") { + await browser.windows.remove(arg); + browser.test.sendMessage("closed"); + } + }); + }, + incognitoOverride: "spanning", + }); + await pb_extension.startup(); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let handlerUrl = await extension.awaitMessage("test-url"); + + // Ensure that the protocol handler is configured, and set it as default to + // bypass the dialog. + let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript); + + let msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup"); + let data = await msg; + ok(data.preferredAction, "using a helper application is the preferred action"); + ok(data.preferredApplicationHandler, "handler was set as default handler"); + is(data.handlers, 1, "one handler is set"); + ok(!data.alwaysAskBeforeHandling, "will not show dialog"); + ok(data.isWebHandler, "the handler is a web handler"); + is(data.uriTemplate, `${handlerUrl}?val=%s`, "correct url template"); + chromeScript.destroy(); + + extension.sendMessage("open", "ext+foo:test"); + let id = await extension.awaitMessage("opened"); + + let query = await extension.awaitMessage("test-query"); + is(query, "?val=ext%2Bfoo%3Atest", "test query ok"); + + extension.sendMessage("close", id); + await extension.awaitMessage("closed"); + + // Test the protocol in a private window, watch for the + // console error. + consoleMonitor.start([{message: /NS_ERROR_FILE_NOT_FOUND/}]); + + // Expect the chooser window to be open, close it. + chromeScript = SpecialPowers.loadChromeScript(async () => { + const CONTENT_HANDLING_URL = "chrome://mozapps/content/handling/appChooser.xhtml"; + const {BrowserTestUtils} = ChromeUtils.import("resource://testing-common/BrowserTestUtils.jsm"); + + let windowOpen = BrowserTestUtils.domWindowOpenedAndLoaded(); + + sendAsyncMessage("listenWindow"); + + let window = await windowOpen; + let gBrowser = window.gBrowser + let tabDialogBox = gBrowser.getTabDialogBox(gBrowser.selectedBrowser); + let dialogStack = tabDialogBox.getTabDialogManager()._dialogStack; + + let checkFn = dialogEvent => + dialogEvent.detail.dialog?._openedURL == CONTENT_HANDLING_URL; + + let eventPromise = BrowserTestUtils.waitForEvent( + dialogStack, + "dialogopen", + true, + checkFn + ); + + sendAsyncMessage("listenDialog"); + + let event = await eventPromise; + + let { dialog } = event.detail; + + let entry = dialog._frame.contentDocument.getElementById("items").firstChild; + sendAsyncMessage("handling", {name: entry.getAttribute("name"), disabled: entry.disabled}); + + dialog.close(); + }); + + // Wait for the chrome script to attach window listener + await chromeScript.promiseOneMessage("listenWindow"); + + let listenDialog = chromeScript.promiseOneMessage("listenDialog"); + let windowOpen = pb_extension.awaitMessage("opened"); + + pb_extension.sendMessage("open", "ext+foo:test"); + + // Wait for chrome script to attach dialog listener + await listenDialog; + let {tabId, windowId} = await windowOpen; + + let testData = chromeScript.promiseOneMessage("handling"); + let navPromise = pb_extension.awaitMessage("navigated"); + pb_extension.sendMessage("nav", {url: "ext+foo:test", tabId}); + await navPromise; + await consoleMonitor.finished(); + let entry = await testData; + + is(entry.name, "a foo protocol handler", "entry is correct"); + ok(entry.disabled, "handler is disabled"); + + let promiseClosed = pb_extension.awaitMessage("closed"); + pb_extension.sendMessage("close", windowId); + await promiseClosed; + await pb_extension.unload(); + + // Shutdown the addon, then ensure the protocol was removed. + await extension.unload(); + chromeScript = SpecialPowers.loadChromeScript(() => { + addMessageListener("setup", () => { + const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo"); + sendAsyncMessage("preferredApplicationHandler", !protoInfo.preferredApplicationHandler); + let handlers = protoInfo.possibleApplicationHandlers; + + sendAsyncMessage("handlerData", { + preferredApplicationHandler: !protoInfo.preferredApplicationHandler, + handlers: handlers.length, + }); + }); + }); + + msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup"); + data = await msg; + ok(data.preferredApplicationHandler, "no preferred handler is set"); + is(data.handlers, 0, "no handler is set"); + chromeScript.destroy(); +}); + +add_task(async function test_protocolHandler_two() { + let extensionData = { + manifest: { + "protocol_handlers": [ + { + "protocol": "ext+foo", + "name": "a foo protocol handler", + "uriTemplate": "foo.html?val=%s", + }, + { + "protocol": "ext+foo", + "name": "another foo protocol handler", + "uriTemplate": "foo2.html?val=%s", + }, + ], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Ensure that the protocol handler is configured, and set it as default, + // but because there are two handlers, the dialog is not bypassed. We + // don't test the actual dialog ui, it's been here forever and works based + // on the alwaysAskBeforeHandling value. + let chromeScript = SpecialPowers.loadChromeScript(protocolChromeScript); + + let msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup"); + let data = await msg; + ok(data.preferredAction, "using a helper application is the preferred action"); + ok(data.preferredApplicationHandler, "preferred handler is set"); + is(data.handlers, 2, "two handlers are set"); + ok(data.alwaysAskBeforeHandling, "will show dialog"); + ok(data.isWebHandler, "the handler is a web handler"); + chromeScript.destroy(); + await extension.unload(); +}); + +add_task(async function test_protocolHandler_https_target() { + let extensionData = { + manifest: { + "protocol_handlers": [ + { + "protocol": "ext+foo", + "name": "http target", + "uriTemplate": "https://example.com/foo.html?val=%s", + }, + ], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + ok(true, "https uriTemplate target works"); + await extension.unload(); +}); + +add_task(async function test_protocolHandler_http_target() { + let extensionData = { + manifest: { + "protocol_handlers": [ + { + "protocol": "ext+foo", + "name": "http target", + "uriTemplate": "http://example.com/foo.html?val=%s", + }, + ], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + ok(true, "http uriTemplate target works"); + await extension.unload(); +}); + +add_task(async function test_protocolHandler_restricted_protocol() { + let extensionData = { + manifest: { + "protocol_handlers": [ + { + "protocol": "http", + "name": "take over the http protocol", + "uriTemplate": "http.html?val=%s", + }, + ], + }, + }; + + consoleMonitor.start([{message: /processing protocol_handlers\.0\.protocol/}]); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects(extension.startup(), + /startup failed/, + "unable to register restricted handler protocol"); + + await consoleMonitor.finished(); +}); + +add_task(async function test_protocolHandler_restricted_uriTemplate() { + let extensionData = { + manifest: { + "protocol_handlers": [ + { + "protocol": "ext+foo", + "name": "take over the http protocol", + "uriTemplate": "ftp://example.com/file.txt", + }, + ], + }, + }; + + consoleMonitor.start([{message: /processing protocol_handlers\.0\.uriTemplate/}]); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects(extension.startup(), + /startup failed/, + "unable to register restricted handler uriTemplate"); + + await consoleMonitor.finished(); +}); + +add_task(async function test_protocolHandler_duplicate() { + let extensionData = { + manifest: { + "protocol_handlers": [ + { + "protocol": "ext+foo", + "name": "foo protocol", + "uriTemplate": "foo.html?val=%s", + }, + { + "protocol": "ext+foo", + "name": "foo protocol", + "uriTemplate": "foo.html?val=%s", + }, + ], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Get the count of handlers installed. + let chromeScript = SpecialPowers.loadChromeScript(() => { + addMessageListener("setup", () => { + const protoSvc = Cc["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Ci.nsIExternalProtocolService); + let protoInfo = protoSvc.getProtocolHandlerInfo("ext+foo"); + let handlers = protoInfo.possibleApplicationHandlers; + sendAsyncMessage("handlerData", handlers.length); + }); + }); + + let msg = chromeScript.promiseOneMessage("handlerData"); + chromeScript.sendAsyncMessage("setup"); + let data = await msg; + is(data, 1, "cannot re-register the same handler config"); + chromeScript.destroy(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html new file mode 100644 index 0000000000..24dc737982 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_redirect_jar.html @@ -0,0 +1,92 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script> +"use strict"; + +function getExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + "applications": { + "gecko": { + "id": "redirect-to-jar@mochi.test", + }, + }, + "permissions": [ + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + "web_accessible_resources": [ + "finished.html", + ], + }, + useAddonManager: "temporary", + files: { + "finished.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>redirected!</h1> + </body> + </html> + `, + }, + background: async () => { + let redirectUrl = browser.extension.getURL("finished.html"); + browser.webRequest.onBeforeRequest.addListener(details => { + return {redirectUrl}; + }, {urls: ["*://*/intercept*"]}, ["blocking"]); + + let code = `new Promise(resolve => { + var s = document.createElement('iframe'); + s.src = "/intercept?r=" + Math.random(); + s.onload = async () => { + let url = await window.wrappedJSObject.SpecialPowers.spawn(s, [], () => content.location.href ); + resolve(['loaded', url]); + } + s.onerror = () => resolve(['error']); + document.documentElement.appendChild(s); + });`; + + async function testSubFrameResource(tabId, code) { + let [result] = await browser.tabs.executeScript(tabId, { code }); + return result; + } + + let tab = await browser.tabs.create({url: "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_sample.html"}); + let result = await testSubFrameResource(tab.id, code); + browser.test.assertEq("loaded", result[0], "frame 1 loaded"); + browser.test.assertEq(redirectUrl, result[1], "frame 1 redirected"); + // If jar caching breaks redirects, this next test will fail (See Bug 1390346). + result = await testSubFrameResource(tab.id, code); + browser.test.assertEq("loaded", result[0], "frame 2 loaded"); + browser.test.assertEq(redirectUrl, result[1], "frame 2 redirected"); + await browser.tabs.remove(tab.id); + browser.test.sendMessage("requestsCompleted"); + }, + }); +} + +add_task(async function test_redirect_to_jar() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("requestsCompleted"); + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html new file mode 100644 index 0000000000..d9a85ad8e4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_request_urlClassification.html @@ -0,0 +1,129 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for WebRequest urlClassification</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.trackingprotection.enabled", true]], + }); + + let chromeScript = SpecialPowers.loadChromeScript(async _ => { + const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm"); + await UrlClassifierTestUtils.addTestTrackers(); + sendAsyncMessage("trackersLoaded"); + }); + await chromeScript.promiseOneMessage("trackersLoaded"); + chromeScript.destroy(); +}); + +add_task(async function test_urlClassification() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: {gecko: {id: "classification@mochi.test"}}, + permissions: ["webRequest", "webRequestBlocking", "proxy", "<all_urls>"], + }, + background() { + let expected = { + "http://tracking.example.org/": {first: "tracking", thirdParty: false, }, + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=tracking.example.org": { thirdParty: false, }, + "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": {third: "tracking", thirdParty: true, }, + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=example.net": { thirdParty: false, }, + "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png": { thirdParty: true, }, + }; + function testRequest(details) { + let expect = expected[details.url]; + if (expect) { + if (expect.first) { + browser.test.assertTrue(details.urlClassification.firstParty.includes("tracking"), "tracking firstParty"); + } else { + browser.test.assertEq(details.urlClassification.firstParty.length, 0, "not tracking firstParty"); + } + if (expect.third) { + browser.test.assertTrue(details.urlClassification.thirdParty.includes("tracking"), "tracking thirdParty"); + } else { + browser.test.assertEq(details.urlClassification.thirdParty.length, 0, "not tracking thirdParty"); + } + + browser.test.assertEq(details.thirdParty, expect.thirdParty, "3rd party flag matches"); + return true; + } + return false; + } + + browser.proxy.onRequest.addListener(details => { + browser.test.log(`proxy.onRequest ${JSON.stringify(details)}`); + testRequest(details); + }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]}); + browser.webRequest.onBeforeRequest.addListener(async (details) => { + browser.test.log(`webRequest.onBeforeRequest ${JSON.stringify(details)}`); + testRequest(details); + }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]}, ["blocking"]); + browser.webRequest.onCompleted.addListener(async (details) => { + browser.test.log(`webRequest.onCompleted ${JSON.stringify(details)}`); + if (testRequest(details)) { + browser.test.sendMessage("classification", details.url); + } + }, {urls: ["http://mochi.test/tests/*", "http://tracking.example.org/*", "http://example.net/*"]}); + }, + }); + await extension.startup(); + + // Test first party tracking classification. + let url = "http://tracking.example.org/"; + let win = window.open(url); + is(await extension.awaitMessage("classification"), url, "request completed"); + win.close(); + + // Test third party tracking classification, expecting two results. + url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=tracking.example.org"; + win = window.open(url); + is(await extension.awaitMessage("classification"), url); + is(await extension.awaitMessage("classification"), + "http://tracking.example.org/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png", + "request completed"); + win.close(); + + // Test third party tracking classification, expecting two results. + url = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_third_party.html?domain=example.net"; + win = window.open(url); + is(await extension.awaitMessage("classification"), url); + is(await extension.awaitMessage("classification"), + "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png", + "request completed"); + win.close(); + + await extension.unload(); +}); + +add_task(async function teardown() { + let chromeScript = SpecialPowers.loadChromeScript(async _ => { + // Cleanup cache + await new Promise(resolve => { + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, value => resolve()); + }); + + /* global sendAsyncMessage */ + const {UrlClassifierTestUtils} = ChromeUtils.import("resource://testing-common/UrlClassifierTestUtils.jsm"); + await UrlClassifierTestUtils.cleanupTestTrackers(); + sendAsyncMessage("trackersUnloaded"); + }); + await chromeScript.promiseOneMessage("trackersUnloaded"); + chromeScript.destroy(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html new file mode 100644 index 0000000000..c4726092ec --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect.html @@ -0,0 +1,82 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.name, "ernie", "port name correct"); + browser.test.assertTrue(port.sender.url.endsWith("file_sample.html"), "URL correct"); + browser.test.assertTrue(port.sender.tab.url.endsWith("file_sample.html"), "tab URL correct"); + + let expected = "message 1"; + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, expected, "message is expected"); + if (expected == "message 1") { + port.postMessage("message 2"); + expected = "message 3"; + } else if (expected == "message 3") { + expected = "disconnect"; + browser.test.notifyPass("runtime.connect"); + } + }); + port.onDisconnect.addListener(() => { + browser.test.assertEq(null, port.error, "No error because port is closed by disconnect() at other end"); + browser.test.assertEq(expected, "disconnect", "got disconnection at right time"); + }); + }); +} + +function contentScript() { + let port = browser.runtime.connect({name: "ernie"}); + port.postMessage("message 1"); + port.onMessage.addListener(msg => { + if (msg == "message 2") { + port.postMessage("message 3"); + port.disconnect(); + } + }); +} + +let extensionData = { + background, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + + await Promise.all([waitForLoad(win), extension.awaitFinish("runtime.connect")]); + + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html new file mode 100644 index 0000000000..13b9029c48 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect2.html @@ -0,0 +1,102 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function backgroundScript(token) { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(msg, "done"); + browser.test.notifyPass("sendmessage_reply"); + }); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertTrue(port.sender.url.endsWith("file_sample.html"), "sender url correct"); + browser.test.assertTrue(port.sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + let tabId = port.sender.tab.id; + browser.tabs.connect(tabId, {name: token}); + + browser.test.assertEq(port.name, token, "token matches"); + port.postMessage(token + "-done"); + }); + + browser.test.sendMessage("background-ready"); +} + +function contentScript(token) { + let gotTabMessage = false; + let badTabMessage = false; + browser.runtime.onConnect.addListener(port => { + if (port.name == token) { + gotTabMessage = true; + } else { + badTabMessage = true; + } + port.disconnect(); + }); + + let port = browser.runtime.connect(null, {name: token}); + port.onMessage.addListener(function(msg) { + if (msg != token + "-done" || !gotTabMessage || badTabMessage) { + return; // test failed + } + + // FIXME: Removing this line causes the test to fail: + // resource://gre/modules/ExtensionUtils.jsm, line 651: NS_ERROR_NOT_INITIALIZED + port.disconnect(); + browser.runtime.sendMessage("done"); + }); +} + +function makeExtension() { + let token = Math.random(); + let extensionData = { + background: `(${backgroundScript})("${token}")`, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }], + }, + + files: { + "content_script.js": `(${contentScript})("${token}")`, + }, + }; + return extensionData; +} + +add_task(async function test_contentscript() { + let extension1 = ExtensionTestUtils.loadExtension(makeExtension()); + let extension2 = ExtensionTestUtils.loadExtension(makeExtension()); + await Promise.all([extension1.startup(), extension2.startup()]); + + await extension1.awaitMessage("background-ready"); + await extension2.awaitMessage("background-ready"); + + let win = window.open("file_sample.html"); + + await Promise.all([waitForLoad(win), + extension1.awaitFinish("sendmessage_reply"), + extension2.awaitFinish("sendmessage_reply")]); + + win.close(); + + await extension1.unload(); + await extension2.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html new file mode 100644 index 0000000000..b671cba23d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_connect_twoway.html @@ -0,0 +1,126 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; + +add_task(async function test_connect_bidirectionally_and_postMessage() { + function background() { + let onConnectCount = 0; + browser.runtime.onConnect.addListener(port => { + // 3. onConnect by connect() from CS. + browser.test.assertEq("from-cs", port.name); + browser.test.assertEq(1, ++onConnectCount, + "BG onConnect should be called once"); + + let tabId = port.sender.tab.id; + browser.test.assertTrue(tabId, "content script must have a tab ID"); + + let port2; + let postMessageCount1 = 0; + port.onMessage.addListener(msg => { + // 11. port.onMessage by port.postMessage in CS. + browser.test.assertEq("from CS to port", msg); + browser.test.assertEq(1, ++postMessageCount1, + "BG port.onMessage should be called once"); + + // 12. should trigger port2.onMessage in CS. + port2.postMessage("from BG to port2"); + }); + + // 4. Should trigger onConnect in CS. + port2 = browser.tabs.connect(tabId, {name: "from-bg"}); + let postMessageCount2 = 0; + port2.onMessage.addListener(msg => { + // 7. onMessage by port2.postMessage in CS. + browser.test.assertEq("from CS to port2", msg); + browser.test.assertEq(1, ++postMessageCount2, + "BG port2.onMessage should be called once"); + + // 8. Should trigger port.onMessage in CS. + port.postMessage("from BG to port"); + }); + }); + + // 1. Notify test runner to create a new tab. + browser.test.sendMessage("ready"); + } + + function contentScript() { + let onConnectCount = 0; + let port; + browser.runtime.onConnect.addListener(port2 => { + // 5. onConnect by connect() from BG. + browser.test.assertEq("from-bg", port2.name); + browser.test.assertEq(1, ++onConnectCount, + "CS onConnect should be called once"); + + let postMessageCount2 = 0; + port2.onMessage.addListener(msg => { + // 12. port2.onMessage by port2.postMessage in BG. + browser.test.assertEq("from BG to port2", msg); + browser.test.assertEq(1, ++postMessageCount2, + "CS port2.onMessage should be called once"); + + // TODO(robwu): Do not explicitly disconnect, it should not be a problem + // if we keep the ports open. However, not closing the ports causes the + // test to fail with NS_ERROR_NOT_INITIALIZED in ExtensionUtils.jsm, in + // Port.prototype.disconnect (nsIMessageSender.sendAsyncMessage). + port.disconnect(); + port2.disconnect(); + browser.test.notifyPass("ping pong done"); + }); + // 6. should trigger port2.onMessage in BG. + port2.postMessage("from CS to port2"); + }); + + // 2. should trigger onConnect in BG. + port = browser.runtime.connect({name: "from-cs"}); + let postMessageCount1 = 0; + port.onMessage.addListener(msg => { + // 9. onMessage by port.postMessage in BG. + browser.test.assertEq("from BG to port", msg); + browser.test.assertEq(1, ++postMessageCount1, + "CS port.onMessage should be called once"); + + // 10. should trigger port.onMessage in BG. + port.postMessage("from CS to port"); + }); + } + + let extensionData = { + background, + manifest: { + content_scripts: [{ + js: ["contentscript.js"], + matches: ["http://mochi.test/*/file_sample.html"], + }], + }, + files: { + "contentscript.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + info("extension loaded"); + + await extension.awaitMessage("ready"); + + let win = window.open("file_sample.html"); + await extension.awaitFinish("ping pong done"); + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> +</body> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html new file mode 100644 index 0000000000..f18190bf8b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_runtime_disconnect.html @@ -0,0 +1,77 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.name, "ernie", "port name correct"); + port.onDisconnect.addListener(() => { + browser.test.assertEq(null, port.error, "The port is implicitly closed without errors when the other context unloads"); + // Closing an already-disconnected port is a no-op. + port.disconnect(); + port.disconnect(); + browser.test.sendMessage("disconnected"); + }); + browser.test.sendMessage("connected"); + }); +} + +function contentScript() { + browser.runtime.connect({name: "ernie"}); +} + +let extensionData = { + background, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + await Promise.all([waitForLoad(win), extension.awaitMessage("connected")]); + win.close(); + await extension.awaitMessage("disconnected"); + + info("win.close() succeeded"); + + win = window.open("file_sample.html"); + await Promise.all([waitForLoad(win), extension.awaitMessage("connected")]); + + // Add an "unload" listener so that we don't put the window in the + // bfcache. This way it gets destroyed immediately upon navigation. + win.addEventListener("unload", function() {}); // eslint-disable-line mozilla/balanced-listeners + + win.location = "http://example.com"; + await extension.awaitMessage("disconnected"); + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html new file mode 100644 index 0000000000..ffdbc90efb --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_doublereply.html @@ -0,0 +1,100 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + // Add two listeners that both send replies. We're supposed to ignore all but one + // of them. Which one is chosen is non-deterministic. + + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + if (msg == "getreply") { + sendReply("reply1"); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + if (msg == "getreply") { + sendReply("reply2"); + } + }); + + function sleep(callback, n = 10) { + if (n == 0) { + callback(); + } else { + setTimeout(function() { sleep(callback, n - 1); }, 0); + } + } + + let done_count = 0; + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + if (msg == "done") { + done_count++; + browser.test.assertEq(done_count, 1, "got exactly one reply"); + + // Go through the event loop a few times to make sure we don't get multiple replies. + sleep(function() { + browser.test.notifyPass("sendmessage_doublereply"); + }); + } + }); +} + +function contentScript() { + browser.runtime.sendMessage("getreply", function(resp) { + if (resp != "reply1" && resp != "reply2") { + return; // test failed + } + browser.runtime.sendMessage("done"); + }); +} + +let extensionData = { + background, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + + await Promise.all([waitForLoad(win), extension.awaitFinish("sendmessage_doublereply")]); + + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html new file mode 100644 index 0000000000..ca151b0216 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_frameId.html @@ -0,0 +1,49 @@ +<!doctype html> +<head> + <title>Test sendMessage frameId</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<script> +"use strict"; + +add_task(async function test_sendMessage_frameId() { + const html = `<!doctype html><meta charset="utf-8"><script src="script.js"><\/script>`; + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "send_message_frame_id@tests.mozilla.org" } }, + }, + background() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.sendMessage(msg, sender); + }); + browser.tabs.create({url: "tab.html"}); + }, + files: { + "iframe.html": html, + "tab.html": `${html}<iframe src="iframe.html"></iframe>`, + "script.js": () => { + browser.runtime.sendMessage(window.top === window ? "tab" : "iframe"); + }, + }, + }); + + await extension.startup(); + + const tab = await extension.awaitMessage("tab"); + ok(tab.url.endsWith("tab.html"), "Got the message from the tab"); + is(tab.frameId, 0, "And sender.frameId is zero"); + + const iframe = await extension.awaitMessage("iframe"); + ok(iframe.url.endsWith("iframe.html"), "Got the message from the iframe"); + is(typeof iframe.frameId, "number", "With sender.frameId of type number"); + ok(iframe.frameId > 0, "And sender.frameId greater than zero"); + + await extension.unload(); +}); + +</script> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html new file mode 100644 index 0000000000..970de26528 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_no_receiver.html @@ -0,0 +1,82 @@ +<!DOCTYPE html> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; + +function loadContentScriptExtension(contentScript) { + let extensionData = { + manifest: { + "content_scripts": [{ + "js": ["contentscript.js"], + "matches": ["http://mochi.test/*/file_sample.html"], + }], + }, + files: { + "contentscript.js": contentScript, + }, + }; + return ExtensionTestUtils.loadExtension(extensionData); +} + +add_task(async function test_content_script_sendMessage_without_listener() { + async function contentScript() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist."); + + browser.test.notifyPass("sendMessage callback was invoked"); + } + + let extension = loadContentScriptExtension(contentScript); + await extension.startup(); + + let win = window.open("file_sample.html"); + await extension.awaitFinish("sendMessage callback was invoked"); + win.close(); + + await extension.unload(); +}); + +add_task(async function test_content_script_chrome_sendMessage_without_listener() { + function contentScript() { + /* globals chrome */ + browser.test.assertEq(null, chrome.runtime.lastError, "no lastError before call"); + let retval = chrome.runtime.sendMessage("msg"); + browser.test.assertEq(null, chrome.runtime.lastError, "no lastError after call"); + // TODO(robwu): Fix the implementation and uncomment the next expectation. + // When content script APIs are schema-based (bugzil.la/1287007) this bug will be fixed for free. + // browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage without callback"); + browser.test.assertTrue(retval instanceof Promise, "TODO: chrome.runtime.sendMessage should return undefined, not a promise"); + + let isAsyncCall = false; + retval = chrome.runtime.sendMessage("msg", reply => { + browser.test.assertEq(undefined, reply, "no reply"); + browser.test.assertTrue(isAsyncCall, "chrome.runtime.sendMessage's callback must be called asynchronously"); + browser.test.assertEq(undefined, retval, "return value of chrome.runtime.sendMessage with callback"); + browser.test.assertEq("Could not establish connection. Receiving end does not exist.", chrome.runtime.lastError.message); + browser.test.notifyPass("finished chrome.runtime.sendMessage"); + }); + isAsyncCall = true; + } + + let extension = loadContentScriptExtension(contentScript); + await extension.startup(); + + let win = window.open("file_sample.html"); + await extension.awaitFinish("finished chrome.runtime.sendMessage"); + win.close(); + + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html new file mode 100644 index 0000000000..a7f6314efd --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply.html @@ -0,0 +1,78 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function background() { + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), "sender url correct"); + + if (msg == 0) { + sendReply("reply1"); + } else if (msg == 1) { + window.setTimeout(function() { + sendReply("reply2"); + }, 0); + return true; + } else if (msg == 2) { + browser.test.notifyPass("sendmessage_reply"); + } + }); +} + +function contentScript() { + browser.runtime.sendMessage(0, function(resp1) { + if (resp1 != "reply1") { + return; // test failed + } + browser.runtime.sendMessage(1, function(resp2) { + if (resp2 != "reply2") { + return; // test failed + } + browser.runtime.sendMessage(2); + }); + }); +} + +let extensionData = { + background, + manifest: { + "permissions": ["tabs"], + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_idle", + }], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let win = window.open("file_sample.html"); + + await Promise.all([waitForLoad(win), extension.awaitFinish("sendmessage_reply")]); + + win.close(); + + await extension.unload(); + info("extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html new file mode 100644 index 0000000000..d3227dbcaf --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_sendmessage_reply2.html @@ -0,0 +1,204 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +function backgroundScript(token, id, otherId) { + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`); + + if (msg === `content-${token}`) { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), + `${id}: sender url correct`); + + let tabId = sender.tab.id; + browser.tabs.sendMessage(tabId, `${token}-contentMessage`); + + sendReply(`${token}-done`); + } else if (msg === `tab-${token}`) { + browser.runtime.sendMessage(otherId, `${otherId}-tabMessage`); + browser.runtime.sendMessage(`${token}-tabMessage`); + + sendReply(`${token}-done`); + } else { + browser.test.fail(`${id}: Unexpected runtime message received: ${msg} ${uneval(sender)}`); + } + }); + + browser.runtime.onMessageExternal.addListener((msg, sender, sendReply) => { + browser.test.assertEq(otherId, sender.id, `${id}: Got expected external sender ID`); + + if (msg === `content-${id}`) { + browser.test.assertTrue(sender.tab.url.endsWith("file_sample.html"), + `${id}: external sender url correct`); + + sendReply(`${otherId}-done`); + } else if (msg === `tab-${id}`) { + sendReply(`${otherId}-done`); + } else if (msg !== `${id}-tabMessage`) { + browser.test.fail(`${id}: Unexpected runtime external message received: ${msg} ${uneval(sender)}`); + } + }); + + browser.tabs.create({url: "tab.html"}); +} + +function contentScript(token, id, otherId) { + let gotContentMessage = false; + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`); + + browser.test.assertEq(`${token}-contentMessage`, msg, + `${id}: Correct content script message`); + if (msg === `${token}-contentMessage`) { + gotContentMessage = true; + } + }); + + Promise.all([ + browser.runtime.sendMessage(otherId, `content-${otherId}`).then(resp => { + browser.test.assertEq(`${id}-done`, resp, `${id}: Correct content script external response token`); + }), + + browser.runtime.sendMessage(`content-${token}`).then(resp => { + browser.test.assertEq(`${token}-done`, resp, `${id}: Correct content script response token`); + }).catch(e => { + browser.test.fail(`content-${token} rejected with ${e.message}`); + }), + ]).then(() => { + browser.test.assertTrue(gotContentMessage, `${id}: Got content script message`); + + browser.test.sendMessage("content-script-done"); + }); +} + +async function tabScript(token, id, otherId) { + let gotTabMessage = false; + browser.runtime.onMessage.addListener((msg, sender, sendReply) => { + browser.test.assertEq(id, sender.id, `${id}: Got expected sender ID`); + + if (String(msg).startsWith("content-")) { + return; + } + + browser.test.assertEq(`${token}-tabMessage`, msg, + `${id}: Correct tab script message`); + if (msg === `${token}-tabMessage`) { + gotTabMessage = true; + } + }); + + browser.test.sendMessage("tab-script-loaded"); + + await new Promise(resolve => { + const listener = (msg) => { + if (msg !== "run-tab-script") { + return; + } + browser.test.onMessage.removeListener(listener); + resolve(); + }; + browser.test.onMessage.addListener(listener); + }); + + Promise.all([ + browser.runtime.sendMessage(otherId, `tab-${otherId}`).then(resp => { + browser.test.assertEq(`${id}-done`, resp, `${id}: Correct tab script external response token`); + }), + + browser.runtime.sendMessage(`tab-${token}`).then(resp => { + browser.test.assertEq(`${token}-done`, resp, `${id}: Correct tab script response token`); + }), + ]).then(() => { + browser.test.assertTrue(gotTabMessage, `${id}: Got tab script message`); + + window.close(); + + browser.test.sendMessage("tab-script-done"); + }); +} + +function makeExtension(id, otherId) { + let token = Math.random(); + + let args = `${token}, ${JSON.stringify(id)}, ${JSON.stringify(otherId)}`; + + let extensionData = { + useAddonManager: "permanent", + background: `(${backgroundScript})(${args})`, + manifest: { + "applications": {"gecko": {id}}, + + "permissions": ["tabs"], + + + "content_scripts": [{ + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + }], + }, + + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="tab.js"><\/script> + </head> + </html>`, + + "tab.js": `(${tabScript})(${args})`, + + "content_script.js": `(${contentScript})(${args})`, + }, + }; + return extensionData; +} + +add_task(async function test_contentscript() { + const ID1 = "sendmessage1@mochitest.mozilla.org"; + const ID2 = "sendmessage2@mochitest.mozilla.org"; + + let extension1 = ExtensionTestUtils.loadExtension(makeExtension(ID1, ID2)); + let extension2 = ExtensionTestUtils.loadExtension(makeExtension(ID2, ID1)); + + await Promise.all([ + extension1.startup(), + extension2.startup(), + extension1.awaitMessage("tab-script-loaded"), + extension2.awaitMessage("tab-script-loaded"), + ]); + + extension1.sendMessage("run-tab-script"); + extension2.sendMessage("run-tab-script"); + + let win = window.open("file_sample.html"); + + await waitForLoad(win); + + await Promise.all([ + extension1.awaitMessage("content-script-done"), + extension2.awaitMessage("content-script-done"), + extension1.awaitMessage("tab-script-done"), + extension2.awaitMessage("tab-script-done"), + ]); + + win.close(); + + await extension1.unload(); + await extension2.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html new file mode 100644 index 0000000000..e02b016419 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_cleanup.html @@ -0,0 +1,235 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const { + ExtensionStorageIDB, +} = SpecialPowers.Cu.import("resource://gre/modules/ExtensionStorageIDB.jsm"); + +const storageTestHelpers = { + storageLocal: { + async writeData() { + await browser.storage.local.set({hello: "world"}); + browser.test.sendMessage("finished"); + }, + + async readData() { + const matchBrowserStorage = await browser.storage.local.get("hello").then(result => { + return (Object.keys(result).length == 1 && result.hello == "world"); + }); + + browser.test.sendMessage("results", {matchBrowserStorage}); + }, + + assertResults({results, keepOnUninstall}) { + if (keepOnUninstall) { + is(results.matchBrowserStorage, true, "browser.storage.local data is still present"); + } else { + is(results.matchBrowserStorage, false, "browser.storage.local data was cleared"); + } + }, + }, + webAPIs: { + async readData() { + let matchLocalStorage = (localStorage.getItem("hello") == "world"); + + let idbPromise = new Promise((resolve, reject) => { + let req = indexedDB.open("test"); + req.onerror = e => { + reject(new Error(`indexedDB open failed with ${e.errorCode}`)); + }; + + req.onupgradeneeded = e => { + // no database, data is not present + resolve(false); + }; + + req.onsuccess = e => { + let db = e.target.result; + let transaction = db.transaction("store", "readwrite"); + let addreq = transaction.objectStore("store").get("hello"); + addreq.onerror = addreqError => { + reject(new Error(`read from indexedDB failed with ${addreqError.errorCode}`)); + }; + addreq.onsuccess = () => { + let match = (addreq.result.value == "world"); + resolve(match); + }; + }; + }); + + await idbPromise.then(matchIDB => { + let result = {matchLocalStorage, matchIDB}; + browser.test.sendMessage("results", result); + }); + }, + + async writeData() { + localStorage.setItem("hello", "world"); + + let idbPromise = new Promise((resolve, reject) => { + let req = indexedDB.open("test"); + req.onerror = e => { + reject(new Error(`indexedDB open failed with ${e.errorCode}`)); + }; + + req.onupgradeneeded = e => { + let db = e.target.result; + db.createObjectStore("store", {keyPath: "name"}); + }; + + req.onsuccess = e => { + let db = e.target.result; + let transaction = db.transaction("store", "readwrite"); + let addreq = transaction.objectStore("store") + .add({name: "hello", value: "world"}); + addreq.onerror = addreqError => { + reject(new Error(`add to indexedDB failed with ${addreqError.errorCode}`)); + }; + addreq.onsuccess = () => { + resolve(); + }; + }; + }); + + await idbPromise.then(() => { + browser.test.sendMessage("finished"); + }); + }, + + assertResults({results, keepOnUninstall}) { + if (keepOnUninstall) { + is(results.matchLocalStorage, true, "localStorage data is still present"); + is(results.matchIDB, true, "indexedDB data is still present"); + } else { + is(results.matchLocalStorage, false, "localStorage data was cleared"); + is(results.matchIDB, false, "indexedDB data was cleared"); + } + }, + }, +}; + +async function test_uninstall({extensionId, writeData, readData, assertResults}) { + // Set the pref to prevent cleaning up storage on uninstall in a separate prefEnv + // so we can pop it below, leaving flags set in the previous prefEnvs unmodified. + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.keepStorageOnUninstall", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: writeData, + manifest: { + applications: {gecko: {id: extensionId}}, + permissions: ["storage"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); + + // Check that we can still see data we wrote to storage but clear the + // "leave storage" flag so our storaged gets cleared on the next uninstall. + // This effectively tests the keepUuidOnUninstall logic, which ensures + // that when we read storage again and check that it is cleared, that + // it is actually a meaningful test! + await SpecialPowers.popPrefEnv(); + + extension = ExtensionTestUtils.loadExtension({ + background: readData, + manifest: { + applications: {gecko: {id: extensionId}}, + permissions: ["storage"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + let results = await extension.awaitMessage("results"); + + assertResults({results, keepOnUninstall: true}); + + await extension.unload(); + + // Read again. This time, our data should be gone. + extension = ExtensionTestUtils.loadExtension({ + background: readData, + manifest: { + applications: {gecko: {id: extensionId}}, + permissions: ["storage"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + results = await extension.awaitMessage("results"); + + assertResults({results, keepOnUninstall: false}); + + await extension.unload(); +} + + +add_task(async function test_setup_keep_uuid_on_uninstall() { + // Use a test-only pref to leave the addonid->uuid mapping around after + // uninstall so that we can re-attach to the same storage (this prefEnv + // is kept for this entire file and cleared automatically once all the + // tests in this file have been executed). + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextensions.keepUuidOnUninstall", true]], + }); +}); + +// Test extension indexedDB and localStorage storages get cleaned up when the +// extension is uninstalled. +add_task(async function test_uninstall_with_webapi_storages() { + await test_uninstall({ + extensionId: "storage.cleanup-WebAPIStorages@tests.mozilla.org", + ...(storageTestHelpers.webAPIs), + }); +}); + +// Test browser.storage.local with JSONFile backend gets cleaned up when the +// extension is uninstalled. +add_task(async function test_uninistall_with_storage_local_file_backend() { + await SpecialPowers.pushPrefEnv({ + set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + }); + + await test_uninstall({ + extensionId: "storage.cleanup-JSONFileBackend@tests.mozilla.org", + ...(storageTestHelpers.storageLocal), + }); + + await SpecialPowers.popPrefEnv(); +}); + +// Repeat the cleanup test when the storage.local IndexedDB backend is enabled. +add_task(async function test_uninistall_with_storage_local_idb_backend() { + await SpecialPowers.pushPrefEnv({ + set: [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], + }); + + await test_uninstall({ + extensionId: "storage.cleanup-IDBBackend@tests.mozilla.org", + ...(storageTestHelpers.storageLocal), + }); + + await SpecialPowers.popPrefEnv(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html new file mode 100644 index 0000000000..3a02f3fb63 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_manager_capabilities.html @@ -0,0 +1,126 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test Storage API </title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function setup() { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["dom.storageManager.enabled", true], + ["dom.storageManager.prompt.testing", true], + ["dom.storageManager.prompt.testing.allow", true], + ], + }); +}); + +add_task(async function test_backgroundScript() { + function background() { + browser.test.assertTrue(navigator.storage !== undefined, "Has storage api interface"); + + // Test estimate. + browser.test.assertTrue("estimate" in navigator.storage, "Has estimate function"); + browser.test.assertEq("function", typeof navigator.storage.estimate, "estimate is function"); + browser.test.assertTrue(navigator.storage.estimate() instanceof Promise, "estimate returns a promise"); + + return browser.test.notifyPass("navigation_storage_api.done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("navigation_storage_api.done"); + await extension.unload(); +}); + +add_task(async function test_contentScript() { + function contentScript() { + // Should not access storage api in non-secure context. + browser.test.assertEq(undefined, navigator.storage, + "A page from the unsecure http protocol " + + "doesn't have access to the navigator.storage API"); + + return browser.test.notifyPass("navigation_storage_api.done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + "matches": ["http://example.com/*/file_sample.html"], + "js": ["content_script.js"], + }], + }, + + files: { + "content_script.js": `(${contentScript})()`, + }, + }); + + await extension.startup(); + + // Open an explicit URL for testing Storage API in an insecure context. + let win = window.open("http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"); + + await extension.awaitFinish("navigation_storage_api.done"); + + await extension.unload(); + win.close(); +}); + +add_task(async function test_contentScriptSecure() { + function contentScript() { + browser.test.assertTrue(navigator.storage !== undefined, "Has storage api interface"); + + // Test estimate. + browser.test.assertTrue("estimate" in navigator.storage, "Has estimate function"); + browser.test.assertEq("function", typeof navigator.storage.estimate, "estimate is function"); + + // The promise that estimate function returns belongs to the content page, + // but the Promise constructor belongs to the content script sandbox. + // Check window.Promise here. + browser.test.assertTrue(navigator.storage.estimate() instanceof window.Promise, "estimate returns a promise"); + + return browser.test.notifyPass("navigation_storage_api.done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [{ + "matches": ["https://example.com/*/file_sample.html"], + "js": ["content_script.js"], + }], + }, + + files: { + "content_script.js": `(${contentScript})()`, + }, + }); + + await extension.startup(); + + // Open an explicit URL for testing Storage API in a secure context. + let win = window.open("file_sample.html"); + + await extension.awaitFinish("navigation_storage_api.done"); + + await extension.unload(); + win.close(); +}); + +add_task(async function cleanup() { + await SpecialPowers.popPrefEnv(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html new file mode 100644 index 0000000000..b0c7425383 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_storage_smoke_test.html @@ -0,0 +1,110 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +// The purpose of this test is making sure that the implementation enabled by +// default for the storage.local and storage.sync APIs does work across all +// platforms/builds/apps +add_task(async function test_storage_smoke_test() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + for (let storageArea of ["sync", "local"]) { + let storage = browser.storage[storageArea]; + + browser.test.assertTrue(!!storage, `StorageArea ${storageArea} is present.`) + + let data = await storage.get(); + browser.test.assertEq(0, Object.keys(data).length, + `Storage starts out empty for ${storageArea}`); + + data = await storage.get("test"); + browser.test.assertEq(0, Object.keys(data).length, + `Can read non-existent keys for ${storageArea}`); + + await storage.set({ + "test1": "test-value1", + "test2": "test-value2", + "test3": "test-value3" + }); + + browser.test.assertEq( + "test-value1", + (await storage.get("test1")).test1, + `Can set and read back single values for ${storageArea}`); + + browser.test.assertEq( + "test-value2", + (await storage.get("test2")).test2, + `Can set and read back single values for ${storageArea}`); + + data = await storage.get(); + browser.test.assertEq(3, Object.keys(data).length, + `Can set and read back all values for ${storageArea}`); + browser.test.assertEq("test-value1", data.test1, + `Can set and read back all values for ${storageArea}`); + browser.test.assertEq("test-value2", data.test2, + `Can set and read back all values for ${storageArea}`); + browser.test.assertEq("test-value3", data.test3, + `Can set and read back all values for ${storageArea}`); + + data = await storage.get(["test1", "test2"]); + browser.test.assertEq(2, Object.keys(data).length, + `Can set and read back array of values for ${storageArea}`); + browser.test.assertEq("test-value1", data.test1, + `Can set and read back array of values for ${storageArea}`); + browser.test.assertEq("test-value2", data.test2, + `Can set and read back array of values for ${storageArea}`); + + await storage.remove("test1"); + data = await storage.get(["test1", "test2"]); + browser.test.assertEq(1, Object.keys(data).length, + `Data can be removed for ${storageArea}`); + browser.test.assertEq("test-value2", data.test2, + `Data can be removed for ${storageArea}`); + + data = await storage.get({ + test1: 1, + test2: 2, + }); + browser.test.assertEq(2, Object.keys(data).length, + `Expected a key-value pair for every property for ${storageArea}`); + browser.test.assertEq(1, data.test1, + `Use default value if key was deleted for ${storageArea}`); + browser.test.assertEq("test-value2", data.test2, + `Use stored value if found for ${storageArea}`); + + await storage.clear(); + data = await storage.get(); + browser.test.assertEq(0, Object.keys(data).length, + `Data is empty after clear for ${storageArea}`); + } + + browser.test.sendMessage("done"); + }, + // Note: when Android supports sync on the java layer we will need to add + // useAddonManager: "permanent" here. Bug 1625257 + manifest: { + permissions: ["storage"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html new file mode 100644 index 0000000000..d1bfbd824b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_multiple.html @@ -0,0 +1,91 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for multiple extensions trying to filterResponseData on the same request</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const TEST_URL = + "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt"; + +add_task(async () => { + const firstExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ requestId }) => { + const filter = browser.webRequest.filterResponseData(requestId); + filter.ondata = event => { + filter.write(new TextEncoder().encode("Start ")); + filter.write(event.data); + filter.disconnect(); + }; + }, + { + urls: [ + "http://example.org/*/file_streamfilter.txt", + ], + }, + ["blocking"] + ); + }, + }); + + const secondExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ requestId }) => { + const filter = browser.webRequest.filterResponseData(requestId); + filter.ondata = event => { + filter.write(event.data); + }; + filter.onstop = event => { + filter.write(new TextEncoder().encode(" End")); + filter.close(); + }; + }, + { + urls: [ + "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt", + ], + }, + ["blocking"] + ); + }, + }); + + await firstExtension.startup(); + await secondExtension.startup(); + + let iframe = document.createElement("iframe"); + iframe.src = TEST_URL; + document.body.appendChild(iframe); + await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true})); + + let content = await SpecialPowers.spawn(iframe, [], async () => { + return this.content.document.body.textContent; + }); + SimpleTest.is(content, "Start Middle\n End", "Correctly intercepted page content"); + + await firstExtension.unload(); + await secondExtension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html new file mode 100644 index 0000000000..2cf15db4e2 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_streamfilter_processswitch.html @@ -0,0 +1,73 @@ +<!DOCTYPE html> +<html> +<head> + <title>Test for using filterResponseData to intercept a cross-origin navigation that will involve a process switch with fission</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const TEST_HOST = "http://example.com/"; +const CROSS_ORIGIN_HOST = "http://example.org/"; +const TEST_PATH = + "tests/toolkit/components/extensions/test/mochitest/file_streamfilter.txt"; + +const TEST_URL = TEST_HOST + TEST_PATH; +const CROSS_ORIGIN_URL = CROSS_ORIGIN_HOST + TEST_PATH; + +add_task(async () => { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ requestId }) => { + const filter = browser.webRequest.filterResponseData(requestId); + filter.ondata = event => { + filter.write(event.data); + }; + filter.onstop = event => { + filter.write(new TextEncoder().encode(" End")); + filter.close(); + }; + }, + { + urls: [ + "http://example.org/*/file_streamfilter.txt", + ], + }, + ["blocking"] + ); + }, + }); + + await extension.startup(); + + let iframe = document.createElement("iframe"); + iframe.src = TEST_URL; + document.body.appendChild(iframe); + await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true})); + + + iframe.src = CROSS_ORIGIN_URL; + await new Promise(resolve => iframe.addEventListener("load", () => resolve(), {once: true})); + + let content = await SpecialPowers.spawn(iframe, [], async () => { + return this.content.document.body.textContent; + }); + SimpleTest.is(content, "Middle\n End", "Correctly intercepted page content"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html new file mode 100644 index 0000000000..f7389236ab --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_subframes_privileges.html @@ -0,0 +1,340 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>WebExtension test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_webext_tab_subframe_privileges() { + function background() { + browser.runtime.onMessage.addListener(async ({msg, success, tabId, error}) => { + if (msg == "webext-tab-subframe-privileges") { + if (success) { + await browser.tabs.remove(tabId); + + browser.test.notifyPass(msg); + } else { + browser.test.log(`Got an unexpected error: ${error}`); + + let tabs = await browser.tabs.query({active: true}); + await browser.tabs.remove(tabs[0].id); + + browser.test.notifyFail(msg); + } + } + }); + browser.tabs.create({url: browser.runtime.getURL("/tab.html")}); + } + + async function tabSubframeScript() { + browser.test.assertTrue(browser.tabs != undefined, + "Subframe of a privileged page has access to privileged APIs"); + if (browser.tabs) { + try { + let tab = await browser.tabs.getCurrent(); + browser.runtime.sendMessage({ + msg: "webext-tab-subframe-privileges", + success: true, + tabId: tab.id, + }); + } catch (e) { + browser.runtime.sendMessage({msg: "webext-tab-subframe-privileges", success: false, error: `${e}`}); + } + } else { + browser.runtime.sendMessage({ + msg: "webext-tab-subframe-privileges", + success: false, + error: `Privileged APIs missing in WebExtension tab sub-frame`, + }); + } + } + + let extensionData = { + background, + files: { + "tab.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="tab-subframe.html"></iframe> + </body> + </html>`, + "tab-subframe.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + <script src="tab-subframe.js"><\/script> + </head> + </html>`, + "tab-subframe.js": tabSubframeScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitFinish("webext-tab-subframe-privileges"); + await extension.unload(); +}); + +add_task(async function test_webext_background_subframe_privileges() { + function backgroundSubframeScript() { + browser.test.assertTrue(browser.tabs != undefined, + "Subframe of a background page has access to privileged APIs"); + browser.test.notifyPass("webext-background-subframe-privileges"); + } + + let extensionData = { + manifest: { + background: { + page: "background.html", + }, + }, + files: { + "background.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="background-subframe.html"></iframe> + </body> + </html>`, + "background-subframe.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + <script src="background-subframe.js"><\/script> + </head> + </html>`, + "background-subframe.js": backgroundSubframeScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitFinish("webext-background-subframe-privileges"); + await extension.unload(); +}); + +add_task(async function test_webext_contentscript_iframe_subframe_privileges() { + function background() { + browser.runtime.onMessage.addListener(({name, hasTabsAPI, hasStorageAPI}) => { + if (name == "contentscript-iframe-loaded") { + browser.test.assertFalse(hasTabsAPI, + "Subframe of a content script privileged iframes has no access to privileged APIs"); + browser.test.assertTrue(hasStorageAPI, + "Subframe of a content script privileged iframes has access to content script APIs"); + + browser.test.notifyPass("webext-contentscript-subframe-privileges"); + } + }); + } + + function subframeScript() { + browser.runtime.sendMessage({ + name: "contentscript-iframe-loaded", + hasTabsAPI: browser.tabs != undefined, + hasStorageAPI: browser.storage != undefined, + }); + } + + function contentScript() { + let iframe = document.createElement("iframe"); + iframe.setAttribute("src", browser.runtime.getURL("/contentscript-iframe.html")); + document.body.appendChild(iframe); + } + + let extensionData = { + background, + manifest: { + "permissions": ["storage"], + "content_scripts": [{ + "matches": ["http://example.com/*"], + "js": ["contentscript.js"], + }], + web_accessible_resources: [ + "contentscript-iframe.html", + ], + }, + files: { + "contentscript.js": contentScript, + "contentscript-iframe.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="contentscript-iframe-subframe.html"></iframe> + </body> + </html>`, + "contentscript-iframe-subframe.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + <script src="contentscript-iframe-subframe.js"><\/script> + </head> + </html>`, + "contentscript-iframe-subframe.js": subframeScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let win = window.open("http://example.com"); + + await extension.awaitFinish("webext-contentscript-subframe-privileges"); + + win.close(); + + await extension.unload(); +}); + +add_task(async function test_webext_background_remote_subframe_privileges() { + function backgroundSubframeScript() { + window.addEventListener("message", evt => { + browser.test.assertEq("http://mochi.test:8888", evt.origin, "postmessage origin ok"); + browser.test.assertFalse(evt.data.tabs, "remote frame cannot access webextension APIs"); + browser.test.assertEq("cookie=monster", evt.data.cookie, "Expected cookie value"); + browser.test.notifyPass("webext-background-subframe-privileges"); + }, {once: true}); + browser.cookies.set({url: "http://mochi.test:8888", name: "cookie", "value": "monster"}); + } + + let extensionData = { + manifest: { + permissions: ["cookies", "*://mochi.test/*", "tabs"], + background: { + page: "background.html", + }, + }, + files: { + "background.html": `<!DOCTYPE> + <head> + <meta charset="utf-8"> + <script src="background-subframe.js"><\/script> + </head> + <body> + <iframe src='${SimpleTest.getTestFileURL("file_remote_frame.html")}'></iframe> + </body> + </html>`, + "background-subframe.js": backgroundSubframeScript, + }, + }; + // Need remote webextensions to be able to load remote content from a background page. + if (!SpecialPowers.getBoolPref("extensions.webextensions.remote", true)) { + return; + } + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitFinish("webext-background-subframe-privileges"); + await extension.unload(); +}); + +// Test a moz-extension:// iframe inside a content iframe in an extension page. +add_task(async function test_sub_subframe_conduit_verified_env() { + let manifest = { + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + all_frames: true, + js: ["cs.js"], + }], + background: { + page: "background.html", + }, + web_accessible_resources: ["iframe.html"], + }; + + let files = { + "iframe.html": `<!DOCTYPE html><meta charset=utf-8> iframe`, + "cs.js"() { + // A compromised content sandbox shouldn't be able to trick the parent + // process into giving it extension privileges by sending false metadata. + async function faker(extensionId, envType) { + try { + let id = envType + "-xyz1234"; + let wgc = this.content.windowGlobalChild; + + let conduit = wgc.getActor("Conduits").openConduit({}, { + id, + envType, + extensionId, + query: ["CreateProxyContext"], + }); + + return await conduit.queryCreateProxyContext({ + childId: id, + extensionId, + envType: "addon_parent", + url: this.content.location.href, + viewType: "tab", + }); + } catch (e) { + return e.message; + } + } + + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("iframe.html"); + + iframe.onload = async () => { + for (let envType of ["content_child", "addon_child"]) { + let msg = await this.wrappedJSObject.SpecialPowers.spawn( + iframe, [browser.runtime.id, envType], faker); + browser.test.sendMessage(envType, msg); + } + }; + document.body.appendChild(iframe); + }, + "background.html": `<!DOCTYPE html> + <meta charset=utf-8> + <iframe src="${SimpleTest.getTestFileURL("file_sample.html")}"> + </iframe> + page + `, + }; + + async function expectErrors(ext, log) { + let err = await ext.awaitMessage("content_child"); + is(err, "Bad sender context envType: content_child"); + + err = await ext.awaitMessage("addon_child"); + is(err, "Unknown sender or wrong actor for recvCreateProxyContext"); + } + + let remote = SpecialPowers.getBoolPref("extensions.webextensions.remote"); + + let badProcess = { message: /Bad {[\w-]+} process: web/ }; + let badPrincipal = { message: /Bad {[\w-]+} principal: http/ }; + consoleMonitor.start(remote ? [badPrincipal, badProcess] : [badProcess]); + + let extension = ExtensionTestUtils.loadExtension({ manifest, files }); + await extension.startup(); + + if (remote) { + info("Need OOP to spoof from a web iframe inside background page."); + await expectErrors(extension); + } + + info("Try spoofing from the web process."); + let win = window.open("./file_sample.html"); + await expectErrors(extension); + win.close(); + + await extension.unload(); + await consoleMonitor.finished(); + info("Conduit creation logged correct exception(s)."); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html new file mode 100644 index 0000000000..7feb1064ba --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_captureTab.html @@ -0,0 +1,301 @@ +<!DOCTYPE HTML> +<html> +<head> + <meta charset="utf-8"> + <title>Tests tabs.captureTab and tabs.captureVisibleTab</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> +<script type="text/javascript"> +"use strict"; + +async function runTest({ html, fullZoom, coords, rect, scale }) { + let url = `data:text/html,${encodeURIComponent(html)}#scroll`; + + async function background({ coords, rect, scale, method, fullZoom }) { + try { + // Wait for the page to load + await new Promise(resolve => { + browser.webNavigation.onCompleted.addListener( + () => resolve(), + {url: [{schemes: ["data"]}]}); + }); + + let [tab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + // TODO: Bug 1665429 - on mobile we ignore zoom for now + if (browser.tabs.setZoom) { + await browser.tabs.setZoom(tab.id, fullZoom ?? 1); + } + + let id = method === "captureVisibleTab" ? tab.windowId : tab.id; + + let [jpeg, png, ...pngs] = await Promise.all([ + browser.tabs[method](id, { format: "jpeg", quality: 95, rect, scale }), + browser.tabs[method](id, { format: "png", quality: 95, rect, scale }), + browser.tabs[method](id, { quality: 95, rect, scale }), + browser.tabs[method](id, { rect, scale }), + ]); + + browser.test.assertTrue( + pngs.every(url => url == png), + "All PNGs are identical" + ); + + browser.test.assertTrue( + jpeg.startsWith("data:image/jpeg;base64,"), + "jpeg is JPEG" + ); + browser.test.assertTrue( + png.startsWith("data:image/png;base64,"), + "png is PNG" + ); + + let promises = [jpeg, png].map( + url => + new Promise(resolve => { + let img = new Image(); + img.src = url; + img.onload = () => resolve(img); + }) + ); + + let width = (rect?.width ?? tab.width) * (scale ?? devicePixelRatio); + let height = (rect?.height ?? tab.height) * (scale ?? devicePixelRatio); + + [jpeg, png] = await Promise.all(promises); + let images = { jpeg, png }; + for (let format of Object.keys(images)) { + let img = images[format]; + + // WGP.drawSnapshot() deals in int coordinates, and rounds down. + browser.test.assertTrue( + Math.abs(width - img.width) <= 1, + `${format} ok image width: ${img.width}, expected: ${width}` + ); + browser.test.assertTrue( + Math.abs(height - img.height) <= 1, + `${format} ok image height ${img.height}, expected: ${height}` + ); + + let canvas = document.createElement("canvas"); + canvas.width = img.width; + canvas.height = img.height; + canvas.mozOpaque = true; + + let ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); + + for (let { x, y, color } of coords) { + x = (x + img.width) % img.width; + y = (y + img.height) % img.height; + let imageData = ctx.getImageData(x, y, 1, 1).data; + + if (format == "png") { + browser.test.assertEq( + `rgba(${color},255)`, + `rgba(${[...imageData]})`, + `${format} image color is correct at (${x}, ${y})` + ); + } else { + // Allow for some deviation in JPEG version due to lossy compression. + const SLOP = 3; + + browser.test.log( + `Testing ${format} image color at (${x}, ${y}), have rgba(${[ + ...imageData, + ]}), expecting approx. rgba(${color},255)` + ); + + browser.test.assertTrue( + Math.abs(color[0] - imageData[0]) <= SLOP, + `${format} image color.red is correct at (${x}, ${y})` + ); + browser.test.assertTrue( + Math.abs(color[1] - imageData[1]) <= SLOP, + `${format} image color.green is correct at (${x}, ${y})` + ); + browser.test.assertTrue( + Math.abs(color[2] - imageData[2]) <= SLOP, + `${format} image color.blue is correct at (${x}, ${y})` + ); + browser.test.assertEq( + 255, + imageData[3], + `${format} image color.alpha is correct at (${x}, ${y})` + ); + } + } + } + + browser.test.notifyPass("captureTab"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("captureTab"); + } + } + + for (let method of ["captureTab", "captureVisibleTab"]) { + let options = { coords, rect, scale, method, fullZoom }; + info(`Testing configuration: ${JSON.stringify(options)}`); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>", "webNavigation"], + }, + + background: `(${background})(${JSON.stringify(options)})`, + }); + + await extension.startup(); + + let testWindow = window.open(url); + await extension.awaitFinish("captureTab"); + + testWindow.close(); + await extension.unload(); + } +} + +async function testEdgeToEdge({ color, fullZoom }) { + let neutral = [0xaa, 0xaa, 0xaa]; + + let html = ` + <!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1"> + </head> + <body style="background-color: rgb(${color})"> + <!-- Fill most of the image with a neutral color to test edge-to-edge scaling. --> + <div style="position: absolute; + left: 2px; + right: 2px; + top: 2px; + bottom: 2px; + background: rgb(${neutral});"></div> + </body> + </html> + `; + + // Check the colors of the first and last pixels of the image, to make + // sure we capture the entire frame, and scale it correctly. + let coords = [ + { x: 0, y: 0, color }, + { x: -1, y: -1, color }, + { x: 300, y: 200, color: neutral }, + ]; + + info(`Test edge to edge color ${color} at fullZoom=${fullZoom}`); + await runTest({ html, fullZoom, coords }); +} + +add_task(async function testCaptureEdgeToEdge() { + await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 1 }); + await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 2 }); + await testEdgeToEdge({ color: [0, 0, 0], fullZoom: 0.5 }); + await testEdgeToEdge({ color: [255, 255, 255], fullZoom: 1 }); +}); + +const tallDoc = `<!DOCTYPE html> + <meta charset=utf-8> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <div style="background: yellow; width: 50%; height: 500px;"></div> + <div id=scroll style="background: red; width: 25%; height: 5000px;"></div> + Opened with the #scroll fragment, scrolls the div ^ into view. +`; + +// Test currently visible viewport is captured if scrolling is involved. +add_task(async function testScrolledViewport() { + await runTest({ + html: tallDoc, + coords: [ + { x: 50, y: 50, color: [255, 0, 0] }, + { x: 50, y: -50, color: [255, 0, 0] }, + { x: -50, y: -50, color: [255, 255, 255] }, + ], + }); +}); + +// Test rect and scale options. +add_task(async function testRectAndScale() { + await runTest({ + html: tallDoc, + rect: { x: 50, y: 50, width: 10, height: 1000 }, + scale: 4, + coords: [ + { x: 0, y: 0, color: [255, 255, 0] }, + { x: -1, y: 0, color: [255, 255, 0] }, + { x: 0, y: -1, color: [255, 0, 0] }, + { x: -1, y: -1, color: [255, 0, 0] }, + ], + }); +}); + +// Test OOP iframes are captured, for Fission compatibility. +add_task(async function testOOPiframe() { + await runTest({ + html: `<!DOCTYPE html> + <meta charset=utf-8> + <meta name="viewport" content="width=device-width, initial-scale=1"> + <iframe src="http://example.net/tests/toolkit/components/extensions/test/mochitest/file_green.html"></iframe> + `, + coords: [ + { x: 50, y: 50, color: [0, 255, 0] }, + { x: 50, y: -50, color: [255, 255, 255] }, + { x: -50, y: 50, color: [255, 255, 255] }, + ], + }); +}); + +add_task(async function testCaptureTabPermissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background() { + browser.test.assertEq( + undefined, + browser.tabs.captureTab, + 'Extension without "<all_urls>" permission should not have access to captureTab' + ); + browser.test.notifyPass("captureTabPermissions"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("captureTabPermissions"); + await extension.unload(); +}); + +add_task(async function testCaptureVisibleTabPermissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background() { + browser.test.assertEq( + undefined, + browser.tabs.captureVisibleTab, + 'Extension without "<all_urls>" permission should not have access to captureVisibleTab' + ); + browser.test.notifyPass("captureVisibleTabPermissions"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("captureVisibleTabPermissions"); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html new file mode 100644 index 0000000000..99d8b77f16 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_permissions.html @@ -0,0 +1,780 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Tabs permissions test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +const URL1 = + "http://www.example.com/tests/toolkit/components/extensions/test/mochitest/file_tabs_permission_page1.html"; +const URL2 = + "http://example.net/tests/toolkit/components/extensions/test/mochitest/file_tabs_permission_page2.html"; + +const helperExtensionDef = { + manifest: { + applications: { + gecko: { + id: "helper@tests.mozilla.org", + }, + }, + permissions: ["webNavigation", "<all_urls>"], + }, + + useAddonManager: "permanent", + + async background() { + browser.test.onMessage.addListener(async message => { + switch (message.subject) { + case "createTab": { + const tabLoaded = new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(function listener( + details + ) { + if (details.url === message.data.url) { + browser.webNavigation.onCompleted.removeListener(listener); + resolve(); + } + }); + }); + + const tab = await browser.tabs.create({ url: message.data.url }); + await tabLoaded; + browser.test.sendMessage("tabCreated", tab.id); + break; + } + + case "changeTabURL": { + const tabLoaded = new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(function listener( + details + ) { + if (details.url === message.data.url) { + browser.webNavigation.onCompleted.removeListener(listener); + resolve(); + } + }); + }); + + await browser.tabs.update(message.data.tabId, { + url: message.data.url, + }); + await tabLoaded; + browser.test.sendMessage("tabURLChanged", message.data.tabId); + break; + } + + case "changeTabHashAndTitle": { + const tabChanged = new Promise(resolve => { + let hasURLChangeInfo = false, + hasTitleChangeInfo = false; + browser.tabs.onUpdated.addListener(function listener( + tabId, + changeInfo, + tab + ) { + if (changeInfo.url?.endsWith(message.data.urlHash)) { + hasURLChangeInfo = true; + } + if (changeInfo.title === message.data.title) { + hasTitleChangeInfo = true; + } + if (hasURLChangeInfo && hasTitleChangeInfo) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + + await browser.tabs.executeScript(message.data.tabId, { + code: ` + document.location.hash = ${JSON.stringify(message.data.urlHash)}; + document.title = ${JSON.stringify(message.data.title)}; + `, + }); + await tabChanged; + browser.test.sendMessage("tabHashAndTitleChanged"); + break; + } + + case "removeTab": { + await browser.tabs.remove(message.data.tabId); + browser.test.sendMessage("tabRemoved"); + break; + } + + default: + browser.test.fail(`Received unexpected message: ${message}`); + } + }); + }, +}; + +/* + * Test tabs.query function + * Check if the correct tabs are queried by url or title based on the granted permissions + */ +async function test_query(testCases, permissions) { + const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "permissions@tests.mozilla.org", + }, + }, + permissions, + }, + + useAddonManager: "permanent", + + async background() { + // wait for start message + const [testCases, tabIdFromURL1, tabIdFromURL2] = await new Promise( + resolve => { + browser.test.onMessage.addListener(message => resolve(message)); + } + ); + + for (const testCase of testCases) { + const query = testCase.query; + const matchingTabs = testCase.matchingTabs; + + let tabQuery = await browser.tabs.query(query); + // ignore other tabs in the window + tabQuery = tabQuery.filter(tab => { + return tab.id === tabIdFromURL1 || tab.id === tabIdFromURL2; + }); + + browser.test.assertEq(matchingTabs, tabQuery.length, `Tabs queried`); + } + // send end message + browser.test.notifyPass("tabs.query"); + }, + }); + + await helperExtension.startup(); + await extension.startup(); + + helperExtension.sendMessage({ + subject: "createTab", + data: { url: URL1 }, + }); + const tabIdFromURL1 = await helperExtension.awaitMessage("tabCreated"); + + helperExtension.sendMessage({ + subject: "createTab", + data: { url: URL2 }, + }); + const tabIdFromURL2 = await helperExtension.awaitMessage("tabCreated"); + + if (permissions.includes("activeTab")) { + extension.grantActiveTab(tabIdFromURL2); + } + + extension.sendMessage([testCases, tabIdFromURL1, tabIdFromURL2]); + await extension.awaitFinish("tabs.query"); + + helperExtension.sendMessage({ + subject: "removeTab", + data: { tabId: tabIdFromURL1 }, + }); + await helperExtension.awaitMessage("tabRemoved"); + + helperExtension.sendMessage({ + subject: "removeTab", + data: { tabId: tabIdFromURL2 }, + }); + await helperExtension.awaitMessage("tabRemoved"); + + await extension.unload(); + await helperExtension.unload(); +} + +// http://www.example.com host permission +add_task(function query_with_host_permission_url1() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 1, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 1, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 1, + }, + { + query: { title: "The Title" }, + matchingTabs: 1, + }, + { + query: { title: "Another Title" }, + matchingTabs: 0, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["*://www.example.com/*"] + ); +}); + +// http://example.net host permission +add_task(function query_with_host_permission_url2() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 0, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 1, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 1, + }, + { + query: { title: "The Title" }, + matchingTabs: 0, + }, + { + query: { title: "Another Title" }, + matchingTabs: 1, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["*://example.net/*"] + ); +}); + +// <all_urls> permission +add_task(function query_with_host_permission_all_urls() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 1, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 2, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 2, + }, + { + query: { title: "The Title" }, + matchingTabs: 1, + }, + { + query: { title: "Another Title" }, + matchingTabs: 1, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["<all_urls>"] + ); +}); + +// tabs permission +add_task(function query_with_tabs_permission() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 1, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 2, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 2, + }, + { + query: { title: "The Title" }, + matchingTabs: 1, + }, + { + query: { title: "Another Title" }, + matchingTabs: 1, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["tabs"] + ); +}); + +// activeTab permission +add_task(function query_with_activeTab_permission() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 0, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 1, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 1, + }, + { + query: { title: "The Title" }, + matchingTabs: 0, + }, + { + query: { title: "Another Title" }, + matchingTabs: 1, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + ["activeTab"] + ); +}); +// no permission +add_task(function query_without_permission() { + return test_query( + [ + { + query: { url: "*://www.example.com/*" }, + matchingTabs: 0, + }, + { + query: { url: "<all_urls>" }, + matchingTabs: 0, + }, + { + query: { url: ["*://www.example.com/*", "*://example.net/*"] }, + matchingTabs: 0, + }, + { + query: { title: "The Title" }, + matchingTabs: 0, + }, + { + query: { title: "Another Title" }, + matchingTabs: 0, + }, + { + query: {}, + matchingTabs: 2, + }, + ], + [] + ); +}); + +/* + * Test tabs.onUpdate and tabs.get function + * Check if the changeInfo or tab object contains the restricted properties + * url and title only when the right permissions are granted + * The tab is updated without causing navigation in order to also test activeTab permission + */ +async function test_restricted_properties( + permissions, + hasRestrictedProperties +) { + const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "permissions@tests.mozilla.org", + }, + }, + permissions, + }, + + useAddonManager: "permanent", + + async background() { + // wait for test start signal and data + const [ + hasRestrictedProperties, + tabId, + urlHash, + title, + ] = await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + resolve(message); + }); + }); + + let hasURLChangeInfo = false, + hasTitleChangeInfo = false; + function onUpdateListener(tabId, changeInfo, tab) { + if (changeInfo.url?.endsWith(urlHash)) { + hasURLChangeInfo = true; + } + if (changeInfo.title === title) { + hasTitleChangeInfo = true; + } + } + browser.tabs.onUpdated.addListener(onUpdateListener); + + // wait for test evaluation signal and data + await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + if (message === "collectTestResults") { + resolve(message); + } + }); + browser.test.sendMessage("waitingForTabPropertyChanges"); + }); + + // check onUpdate changeInfo + browser.test.assertEq( + hasRestrictedProperties, + hasURLChangeInfo, + `Has changeInfo property "url"` + ); + browser.test.assertEq( + hasRestrictedProperties, + hasTitleChangeInfo, + `Has changeInfo property "title"` + ); + // check tab properties + const tabGet = await browser.tabs.get(tabId); + browser.test.assertEq( + hasRestrictedProperties, + !!tabGet.url?.endsWith(urlHash), + `Has tab property "url"` + ); + browser.test.assertEq( + hasRestrictedProperties, + tabGet.title === title, + `Has tab property "title"` + ); + // send end message + browser.test.notifyPass("tabs.restricted_properties"); + }, + }); + + const urlHash = "#ChangedURL"; + const title = "Changed Title"; + + await helperExtension.startup(); + await extension.startup(); + + helperExtension.sendMessage({ + subject: "createTab", + data: { url: URL1 }, + }); + const tabId = await helperExtension.awaitMessage("tabCreated"); + + if (permissions.includes("activeTab")) { + extension.grantActiveTab(tabId); + } + // send test start signal and data + extension.sendMessage([hasRestrictedProperties, tabId, urlHash, title]); + await extension.awaitMessage("waitingForTabPropertyChanges"); + + helperExtension.sendMessage({ + subject: "changeTabHashAndTitle", + data: { + tabId, + urlHash, + title, + }, + }); + await helperExtension.awaitMessage("tabHashAndTitleChanged"); + + // send end signal and evaluate results + extension.sendMessage("collectTestResults"); + await extension.awaitFinish("tabs.restricted_properties"); + + helperExtension.sendMessage({ + subject: "removeTab", + data: { tabId }, + }); + await helperExtension.awaitMessage("tabRemoved"); + + await extension.unload(); + await helperExtension.unload(); +} + +// http://www.example.com host permission +add_task(function has_restricted_properties_with_host_permission_url1() { + return test_restricted_properties(["*://www.example.com/*"], true); +}); +// http://example.net host permission +add_task(function has_restricted_properties_with_host_permission_url2() { + return test_restricted_properties(["*://example.net/*"], false); +}); +// <all_urls> permission +add_task(function has_restricted_properties_with_host_permission_all_urls() { + return test_restricted_properties(["<all_urls>"], true); +}); +// tabs permission +add_task(function has_restricted_properties_with_tabs_permission() { + return test_restricted_properties(["tabs"], true); +}); +// activeTab permission +add_task(function has_restricted_properties_with_activeTab_permission() { + return test_restricted_properties(["activeTab"], true); +}).skip(); // TODO bug 1686080: support changeInfo.url with activeTab +// no permission +add_task(function has_restricted_properties_without_permission() { + return test_restricted_properties([], false); +}); + + +/* + * Test tabs.onUpdate filter functionality + * Check if the restricted filter properties only work if the + * right permissions are granted + */ +async function test_onUpdateFilter(testCases, permissions) { + // Filters for onUpdated are not supported on Android. + if (AppConstants.platform === "android") { + return; + } + + const helperExtension = ExtensionTestUtils.loadExtension(helperExtensionDef); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "permissions@tests.mozilla.org", + }, + }, + permissions, + }, + + useAddonManager: "permanent", + + async background() { + let listenerGotCalled = false; + function onUpdateListener(tabId, changeInfo, tab) { + listenerGotCalled = true; + } + + browser.test.onMessage.addListener(async message => { + switch (message.subject) { + case "setup": { + browser.tabs.onUpdated.addListener( + onUpdateListener, + message.data.filter + ); + browser.test.sendMessage("done"); + break; + } + + case "collectTestResults": { + browser.test.assertEq( + message.data.expectEvent, + listenerGotCalled, + `Update listener called` + ); + browser.tabs.onUpdated.removeListener(onUpdateListener); + listenerGotCalled = false; + browser.test.sendMessage("done"); + break; + } + + default: + browser.test.fail(`Received unexpected message: ${message}`); + } + }); + }, + }); + + await helperExtension.startup(); + await extension.startup(); + + for (const testCase of testCases) { + helperExtension.sendMessage({ + subject: "createTab", + data: { url: URL1 }, + }); + const tabId = await helperExtension.awaitMessage("tabCreated"); + + extension.sendMessage({ + subject: "setup", + data: { + filter: testCase.filter, + }, + }); + await extension.awaitMessage("done"); + + helperExtension.sendMessage({ + subject: "changeTabURL", + data: { + tabId, + url: URL2, + }, + }); + await helperExtension.awaitMessage("tabURLChanged"); + + extension.sendMessage({ + subject: "collectTestResults", + data: { + expectEvent: testCase.expectEvent, + }, + }); + await extension.awaitMessage("done"); + + helperExtension.sendMessage({ + subject: "removeTab", + data: { tabId }, + }); + await helperExtension.awaitMessage("tabRemoved"); + } + + await extension.unload(); + await helperExtension.unload(); +} + +// http://mozilla.org host permission +add_task(function onUpdateFilter_with_host_permission_url3() { + return test_onUpdateFilter( + [ + { + filter: { urls: ["*://mozilla.org/*"] }, + expectEvent: false, + }, + { + filter: { urls: ["<all_urls>"] }, + expectEvent: false, + }, + { + filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] }, + expectEvent: false, + }, + { + filter: { properties: ["title"] }, + expectEvent: false, + }, + { + filter: {}, + expectEvent: true, + }, + ], + ["*://mozilla.org/*"] + ); +}); + +// http://example.net host permission +add_task(function onUpdateFilter_with_host_permission_url2() { + return test_onUpdateFilter( + [ + { + filter: { urls: ["*://mozilla.org/*"] }, + expectEvent: false, + }, + { + filter: { urls: ["<all_urls>"] }, + expectEvent: true, + }, + { + filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] }, + expectEvent: true, + }, + { + filter: { properties: ["title"] }, + expectEvent: true, + }, + { + filter: {}, + expectEvent: true, + }, + ], + ["*://example.net/*"] + ); +}); + +// <all_urls> permission +add_task(function onUpdateFilter_with_host_permission_all_urls() { + return test_onUpdateFilter( + [ + { + filter: { urls: ["*://mozilla.org/*"] }, + expectEvent: false, + }, + { + filter: { urls: ["<all_urls>"] }, + expectEvent: true, + }, + { + filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] }, + expectEvent: true, + }, + { + filter: { properties: ["title"] }, + expectEvent: true, + }, + { + filter: {}, + expectEvent: true, + }, + ], + ["<all_urls>"] + ); +}); + +// tabs permission +add_task(function onUpdateFilter_with_tabs_permission() { + return test_onUpdateFilter( + [ + { + filter: { urls: ["*://mozilla.org/*"] }, + expectEvent: false, + }, + { + filter: { urls: ["<all_urls>"] }, + expectEvent: true, + }, + { + filter: { urls: ["*://mozilla.org/*", "*://example.net/*"] }, + expectEvent: true, + }, + { + filter: { properties: ["title"] }, + expectEvent: true, + }, + { + filter: {}, + expectEvent: true, + }, + ], + ["tabs"] + ); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html new file mode 100644 index 0000000000..6393114c5f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_query_popup.html @@ -0,0 +1,95 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Tabs create Test</title> + <script type="text/javascript" src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +async function test_query(query) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "current-window@tests.mozilla.org", + } + }, + permissions: ["tabs"], + browser_action: { + default_popup: "popup.html", + }, + }, + + useAddonManager: "permanent", + + background: async function() { + let query = await new Promise(resolve => { + browser.test.onMessage.addListener(message => { + resolve(message); + }); + }); + let tab = await browser.tabs.create({ url: "http://www.example.com", active: true }); + browser.runtime.onMessage.addListener(message => { + if (message === "popup-loaded") { + browser.runtime.sendMessage({ tab, query }); + } + }); + browser.test.withHandlingUserInput(() => + browser.browserAction.openPopup() + ); + }, + + files: { + "popup.html": `<!DOCTYPE html><meta charset="utf-8"><script src="popup.js"><\/script>`, + "popup.js"() { + browser.runtime.onMessage.addListener(async function({ tab, query }) { + let tabs = await browser.tabs.query(query); + browser.test.assertEq(tabs.length, 1, `Got one tab`); + browser.test.assertEq(tabs[0].id, tab.id, "The tab is the right one"); + + // Create a new tab and verify that we still see the right result + let newTab = await browser.tabs.create({ url: "http://www.example.com", active: true }); + tabs = await browser.tabs.query(query); + browser.test.assertEq(tabs.length, 1, `Got one tab`); + browser.test.assertEq(tabs[0].id, newTab.id, "Got the newly-created tab"); + + await browser.tabs.remove(newTab.id); + + // Remove the tab and verify that we see the old tab + tabs = await browser.tabs.query(query); + browser.test.assertEq(tabs.length, 1, `Got one tab`); + browser.test.assertEq(tabs[0].id, tab.id, "Got the tab that was active before"); + + // Cleanup + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.query"); + }); + browser.runtime.sendMessage("popup-loaded"); + }, + }, + }); + + await extension.startup(); + extension.sendMessage(query); + await extension.awaitFinish("tabs.query"); + await extension.unload(); +} + +add_task(function test_query_currentWindow_from_popup() { + return test_query({ currentWindow: true, active: true }); +}); + +add_task(function test_query_lastActiveWindow_from_popup() { + return test_query({ lastFocusedWindow: true, active: true }); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html new file mode 100644 index 0000000000..293914fe5d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_tabs_sendMessage.html @@ -0,0 +1,95 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test tabs.sendMessage</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> +<script> +"use strict"; + +add_task(async function test_tabs_sendMessage_to_extension_page_frame() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { + gecko: { id: "blah@android" }, + }, + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html?tabs.sendMessage"], + js: ["cs.js"], + }], + web_accessible_resources: ["page.html", "page.js"], + }, + + async background() { + let tab; + + browser.runtime.onMessage.addListener(async (msg, sender) => { + browser.test.assertEq(msg, "page-script-ready"); + browser.test.assertEq(sender.url, browser.runtime.getURL("page.html")); + + let tabId = sender.tab.id; + let response = await browser.tabs.sendMessage(tabId, "tab-sendMessage"); + + switch (response) { + case "extension-tab": + browser.test.assertEq(tab.id, tabId, "Extension tab responded"); + browser.test.assertEq(sender.frameId, 0, "Response from top level"); + await browser.tabs.remove(tab.id); + browser.test.sendMessage("extension-tab-responded"); + break; + + case "extension-frame": + browser.test.assertTrue(sender.frameId > 0, "Response from iframe"); + browser.test.sendMessage("extension-frame-responded"); + break; + + default: + browser.test.fail("Unexpected response: " + response); + } + }); + + tab = await browser.tabs.create({ url: "page.html" }); + }, + + files: { + "cs.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("page.html"); + document.body.append(iframe); + browser.test.sendMessage("content-script-done"); + }, + + "page.html": `<!DOCTYPE html> + <meta charset=utf-8> + <script src=page.js><\/script> + Extension page`, + + "page.js"() { + browser.runtime.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "tab-sendMessage"); + return window.parent === window ? "extension-tab" : "extension-frame"; + }); + browser.runtime.sendMessage("page-script-ready"); + }, + } + }); + + await extension.startup(); + await extension.awaitMessage("extension-tab-responded"); + + let win = window.open("file_sample.html?tabs.sendMessage"); + await extension.awaitMessage("content-script-done"); + await extension.awaitMessage("extension-frame-responded"); + win.close(); + + await extension.unload(); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_test.html b/toolkit/components/extensions/test/mochitest/test_ext_test.html new file mode 100644 index 0000000000..9fef13d8d4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_test.html @@ -0,0 +1,196 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Testing test</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> + +<script> +"use strict"; + +function loadExtensionAndInterceptTest(extensionData) { + let results = []; + let testResolve; + let testDone = new Promise(resolve => { testResolve = resolve; }); + let handler = { + testResult(...result) { + result.pop(); + results.push(result); + SimpleTest.info(`Received test result: ${JSON.stringify(result)}`); + }, + + testMessage(msg, ...args) { + results.push(["test-message", msg, ...args]); + SimpleTest.info(`Received message: ${msg} ${JSON.stringify(args)}`); + if (msg === "This is the last browser.test call") { + testResolve(); + } + }, + }; + let extension = SpecialPowers.loadExtension(extensionData, handler); + SimpleTest.registerCleanupFunction(() => { + if (extension.state == "pending" || extension.state == "running") { + SimpleTest.ok(false, "Extension left running at test shutdown"); + return extension.unload(); + } else if (extension.state == "unloading") { + SimpleTest.ok(false, "Extension not fully unloaded at test shutdown"); + } + }); + extension.awaitResults = () => testDone.then(() => results); + return extension; +} + +function testScript() { + // Note: The result of these browser.test calls are intercepted by the test. + // See verifyTestResults for the expectations of each browser.test call. + browser.test.notifyPass("dot notifyPass"); + browser.test.notifyFail("dot notifyFail"); + browser.test.log("dot log"); + browser.test.fail("dot fail"); + browser.test.succeed("dot succeed"); + browser.test.assertTrue(true); + browser.test.assertFalse(false); + browser.test.assertEq("", ""); + + let obj = {}; + let arr = []; + let dom = document.createElement("body"); + browser.test.assertTrue(obj, "Object truthy"); + browser.test.assertTrue(arr, "Array truthy"); + browser.test.assertTrue(dom, "Element truthy"); + browser.test.assertTrue(true, "True truthy"); + browser.test.assertTrue(false, "False truthy"); + browser.test.assertTrue(null, "Null truthy"); + browser.test.assertTrue(undefined, "Void truthy"); + browser.test.assertTrue(false, document.createElement("html")); + + browser.test.assertFalse(obj, "Object falsey"); + browser.test.assertFalse(arr, "Array falsey"); + browser.test.assertFalse(dom, "Element falsey"); + browser.test.assertFalse(true, "True falsey"); + browser.test.assertFalse(false, "False falsey"); + browser.test.assertFalse(null, "Null falsey"); + browser.test.assertFalse(undefined, "Void falsey"); + browser.test.assertFalse(true, document.createElement("head")); + + browser.test.assertEq(obj, obj, "Object equality"); + browser.test.assertEq(arr, arr, "Array equality"); + browser.test.assertEq(dom, dom, "Element equality"); + browser.test.assertEq(null, null, "Null equality"); + browser.test.assertEq(undefined, undefined, "Void equality"); + + browser.test.assertEq({}, {}, "Object reference ineqality"); + browser.test.assertEq([], [], "Array reference ineqality"); + browser.test.assertEq(dom, document.createElement("body"), "Element ineqality"); + browser.test.assertEq(null, undefined, "Null and void ineqality"); + browser.test.assertEq(true, false, document.createElement("div")); + + obj = { + toString() { + return "Dynamic toString forbidden"; + }, + }; + browser.test.assertEq(obj, obj, "obj with dynamic toString()"); + browser.test.assertThrows( + () => { throw new Error("dummy"); }, + /dummy2/, + "intentional failure" + ); + browser.test.sendMessage("Ran test at", location.protocol); + browser.test.sendMessage("This is the last browser.test call"); +} + +function verifyTestResults(results, shortName, expectedProtocol) { + let expectations = [ + ["test-done", true, "dot notifyPass"], + ["test-done", false, "dot notifyFail"], + ["test-log", true, "dot log"], + ["test-result", false, "dot fail"], + ["test-result", true, "dot succeed"], + ["test-result", true, "undefined"], + ["test-result", true, "undefined"], + ["test-eq", true, "undefined", "", ""], + + ["test-result", true, "Object truthy"], + ["test-result", true, "Array truthy"], + ["test-result", true, "Element truthy"], + ["test-result", true, "True truthy"], + ["test-result", false, "False truthy"], + ["test-result", false, "Null truthy"], + ["test-result", false, "Void truthy"], + ["test-result", false, "[object HTMLHtmlElement]"], + + ["test-result", false, "Object falsey"], + ["test-result", false, "Array falsey"], + ["test-result", false, "Element falsey"], + ["test-result", false, "True falsey"], + ["test-result", true, "False falsey"], + ["test-result", true, "Null falsey"], + ["test-result", true, "Void falsey"], + ["test-result", false, "[object HTMLHeadElement]"], + + ["test-eq", true, "Object equality", "[object Object]", "[object Object]"], + ["test-eq", true, "Array equality", "", ""], + ["test-eq", true, "Element equality", "[object HTMLBodyElement]", "[object HTMLBodyElement]"], + ["test-eq", true, "Null equality", "null", "null"], + ["test-eq", true, "Void equality", "undefined", "undefined"], + + ["test-eq", false, "Object reference ineqality", "[object Object]", "[object Object] (different)"], + ["test-eq", false, "Array reference ineqality", "", " (different)"], + ["test-eq", false, "Element ineqality", "[object HTMLBodyElement]", "[object HTMLBodyElement] (different)"], + ["test-eq", false, "Null and void ineqality", "null", "undefined"], + ["test-eq", false, "[object HTMLDivElement]", "true", "false"], + + ["test-eq", true, "obj with dynamic toString()", "[object Object]", "[object Object]"], + ["test-result", false, "Function threw, expecting error to match /dummy2/, got \"dummy\": intentional failure"], + + ["test-message", "Ran test at", expectedProtocol], + ["test-message", "This is the last browser.test call"], + ]; + + expectations.forEach((expectation, i) => { + let msg = expectation.slice(2).join(" - "); + isDeeply(results[i], expectation, `${shortName} (${msg})`); + }); + is(results[expectations.length], undefined, "No more results"); +} + +add_task(async function test_test_in_background() { + let extensionData = { + background: `(${testScript})()`, + }; + + let extension = loadExtensionAndInterceptTest(extensionData); + await extension.startup(); + let results = await extension.awaitResults(); + verifyTestResults(results, "background page", "moz-extension:"); + await extension.unload(); +}); + +add_task(async function test_test_in_content_script() { + let extensionData = { + manifest: { + content_scripts: [{ + matches: ["http://mochi.test/*/file_sample.html"], + js: ["contentscript.js"], + }], + }, + files: { + "contentscript.js": `(${testScript})()`, + }, + }; + + let extension = loadExtensionAndInterceptTest(extensionData); + await extension.startup(); + let win = window.open("file_sample.html"); + let results = await extension.awaitResults(); + win.close(); + verifyTestResults(results, "content script", "http:"); + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html new file mode 100644 index 0000000000..b92de8ab4f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage.html @@ -0,0 +1,139 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_unlimitedStorage.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + +"use strict"; + +async function test_background_storagePersist(EXTENSION_ID) { + await SpecialPowers.pushPrefEnv({ + "set": [ + ["dom.storageManager.enabled", true], + ["dom.storageManager.prompt.testing", false], + ["dom.storageManager.prompt.testing.allow", false], + ], + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + manifest: { + permissions: ["storage", "unlimitedStorage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + + background: async function() { + const PROMISE_RACE_TIMEOUT = 8000; + + browser.test.sendMessage("extension-uuid", window.location.host); + + await browser.storage.local.set({testkey: "testvalue"}); + await browser.test.sendMessage("storage-local-called"); + + const requestStoragePersist = async () => { + const persistAllowed = await navigator.storage.persist(); + if (!persistAllowed) { + throw new Error("navigator.storage.persist() has been denied"); + } + }; + + await Promise.race([ + requestStoragePersist(), + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error("Timeout opening persistent db from background page")); + }, PROMISE_RACE_TIMEOUT); + }), + ]).then( + () => { + browser.test.notifyPass("indexeddb-storagePersistent-unlimitedStorage-done"); + }, + (error) => { + browser.test.fail(`error while testing persistent IndexedDB storage: ${error}`); + browser.test.notifyFail("indexeddb-storagePersistent-unlimitedStorage-done"); + } + ); + }, + }); + + await extension.startup(); + + const uuid = await extension.awaitMessage("extension-uuid"); + + await extension.awaitMessage("storage-local-called"); + + let chromeScript = SpecialPowers.loadChromeScript(function test_country_data() { + const {addMessageListener, sendAsyncMessage} = this; + + addMessageListener("getPersistedStatus", (uuid) => { + const { + ExtensionStorageIDB, + } = ChromeUtils.import("resource://gre/modules/ExtensionStorageIDB.jsm"); + + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + + const {WebExtensionPolicy} = Cu.getGlobalForObject(ExtensionStorageIDB); + const policy = WebExtensionPolicy.getByHostname(uuid); + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal(policy.extension); + const request = Services.qms.persisted(storagePrincipal); + request.callback = () => { + // request.result will be undeinfed if the request failed (request.resultCode !== Cr.NS_OK). + sendAsyncMessage("gotPersistedStatus", request.result); + }; + }); + }); + + const persistedPromise = chromeScript.promiseOneMessage("gotPersistedStatus"); + chromeScript.sendAsyncMessage("getPersistedStatus", uuid); + is(await persistedPromise, true, "Got the expected persist status for the storagePrincipal"); + + await extension.awaitFinish("indexeddb-storagePersistent-unlimitedStorage-done"); + await extension.unload(); + + checkSitePermissions(uuid, Services.perms.UNKNOWN_ACTION, "has been cleared"); +} + +add_task(async function test_unlimitedStorage() { + const EXTENSION_ID = "test-storagePersist@mozilla"; + await SpecialPowers.pushPrefEnv({ + "set": [ + ["extensions.webextensions.ExtensionStorageIDB.enabled", true], + ], + }); + + // Verify persist mode enabled when the storage.local IDB database is opened from + // the main process (from parent/ext-storage.js). + info("Test unlimitedStorage on an extension migrating to the IndexedDB storage.local backend)"); + await test_background_storagePersist(EXTENSION_ID); + + await SpecialPowers.pushPrefEnv({ + "set": [ + [`extensions.webextensions.ExtensionStorageIDB.migrated.` + EXTENSION_ID, true], + ], + }); + + // Verify persist mode enabled when the storage.local IDB database is opened from + // the child process (from child/ext-storage.js). + info("Test unlimitedStorage on an extension migrated to the IndexedDB storage.local backend"); + await test_background_storagePersist(EXTENSION_ID); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage_legacy_persistent_indexedDB.html b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage_legacy_persistent_indexedDB.html new file mode 100644 index 0000000000..fe06d22e8d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_unlimitedStorage_legacy_persistent_indexedDB.html @@ -0,0 +1,81 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_unlimitedStorage.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + +"use strict"; + +add_task(async function test_legacy_indexedDB_storagePersistent_unlimitedStorage() { + const EXTENSION_ID = "test-idbStoragePersistent@mozilla"; + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + manifest: { + permissions: ["unlimitedStorage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + + background: async function() { + const PROMISE_RACE_TIMEOUT = 8000; + + browser.test.sendMessage("extension-uuid", window.location.host); + + try { + await Promise.race([ + new Promise((resolve, reject) => { + const dbReq = indexedDB.open("test-persistent-idb", {version: 1.0, storage: "persistent"}); + + dbReq.onerror = evt => { + reject(evt.target.error); + }; + + dbReq.onsuccess = () => { + resolve(); + }; + }), + new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error("Timeout opening persistent db from background page")); + }, PROMISE_RACE_TIMEOUT); + }), + ]); + + browser.test.notifyPass("indexeddb-storagePersistent-unlimitedStorage-done"); + } catch (error) { + const loggedError = error instanceof DOMException ? error.message : error; + browser.test.fail(`error while testing persistent IndexedDB storage: ${loggedError}`); + browser.test.notifyFail("indexeddb-storagePersistent-unlimitedStorage-done"); + } + }, + }); + + await extension.startup(); + + const uuid = await extension.awaitMessage("extension-uuid"); + + await extension.awaitFinish("indexeddb-storagePersistent-unlimitedStorage-done"); + + await extension.unload(); + + checkSitePermissions(uuid, Services.perms.UNKNOWN_ACTION, "has been cleared"); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html new file mode 100644 index 0000000000..5c9de814e4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_incognito.html @@ -0,0 +1,174 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test the web_accessible_resources incognito</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer; + +async function testImageLoading(src, expectedAction) { + let imageLoadingPromise = new Promise((resolve, reject) => { + let cleanupListeners; + let testImage = new window.Image(); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testImage.wrappedJSObject.setAttribute("src", src); + + let loadListener = () => { + cleanupListeners(); + resolve(expectedAction === "loaded"); + }; + + let errorListener = (event) => { + cleanupListeners(); + resolve(expectedAction === "blocked"); + browser.test.log(`+++ image loading ${event.error}`); + }; + + cleanupListeners = () => { + testImage.removeEventListener("load", loadListener); + testImage.removeEventListener("error", errorListener); + }; + + testImage.addEventListener("load", loadListener); + testImage.addEventListener("error", errorListener); + }); + + let success = await imageLoadingPromise; + browser.runtime.sendMessage({name: "image-loading", expectedAction, success}); +} + +function testScript() { + window.postMessage("test-script-loaded", "*"); +} + +add_task(async function test_web_accessible_resources_incognito() { + await SpecialPowers.pushPrefEnv({set: [ + ["extensions.allowPrivateBrowsingByDefault", false], + ]}); + + // This extension will not have access to private browsing so its + // accessible resources should not be able to load in them. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "web_accessible_resources": [ + "image.png", + "test_script.js", + "accessible.html", + ], + }, + background() { + browser.test.sendMessage("url", browser.extension.getURL("")); + }, + files: { + "image.png": IMAGE_ARRAYBUFFER, + "test_script.js": testScript, + "accessible.html": `<html><head> + <meta charset="utf-8"> + </head></html>`, + }, + }); + + await extension.startup(); + let baseUrl = await extension.awaitMessage("url"); + + async function content() { + let baseUrl = await browser.runtime.sendMessage({name: "get-url"}); + testImageLoading(`${baseUrl}image.png`, "loaded"); + + let testScriptElement = document.createElement("script"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testScriptElement.wrappedJSObject.setAttribute("src", `${baseUrl}test_script.js`); + document.head.appendChild(testScriptElement); + + let iframe = document.createElement("iframe"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + iframe.wrappedJSObject.setAttribute("src", `${baseUrl}accessible.html`); + document.body.appendChild(iframe); + + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("message", event => { + browser.runtime.sendMessage({"name": event.data}); + }); + } + + let pb_extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs"], + content_scripts: [{ + "matches": ["*://example.com/*/file_sample.html"], + "run_at": "document_end", + "js": ["content_script_helper.js", "content_script.js"], + }], + }, + files: { + "content_script_helper.js": `${testImageLoading}`, + "content_script.js": content, + }, + background() { + let url = "http://example.com/tests/toolkit/components/extensions/test/mochitest/file_sample.html"; + let baseUrl; + let window; + + browser.runtime.onMessage.addListener(async msg => { + switch (msg.name) { + case "image-loading": + browser.test.assertFalse(msg.success, `Image was ${msg.expectedAction}`); + browser.test.sendMessage(`image-${msg.expectedAction}`); + break; + case "get-url": + return baseUrl; + default: + browser.test.fail(`unexepected message ${msg.name}`); + } + }); + + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "start") { + baseUrl = data; + window = await browser.windows.create({url, incognito: true}); + } + if (msg == "close") { + browser.windows.remove(window.id); + } + }); + }, + }); + await pb_extension.startup(); + + consoleMonitor.start([ + {message: /may not load or link to.*image.png/}, + {message: /may not load or link to.*test_script.js/}, + {message: /\<script\> source URI is not allowed in this document/}, + {message: /may not load or link to.*accessible.html/}, + ]); + + pb_extension.sendMessage("start", baseUrl); + + await pb_extension.awaitMessage("image-loaded"); + + pb_extension.sendMessage("close"); + + await extension.unload(); + await pb_extension.unload(); + + await consoleMonitor.finished(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html new file mode 100644 index 0000000000..d6ae4358d4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_web_accessible_resources.html @@ -0,0 +1,265 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test the web_accessible_resources manifest directive</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +SimpleTest.registerCleanupFunction(() => { + SpecialPowers.clearUserPref("security.mixed_content.block_display_content"); +}); + +let image = atob("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)).buffer; + +async function testImageLoading(src, expectedAction) { + let imageLoadingPromise = new Promise((resolve, reject) => { + let cleanupListeners; + let testImage = document.createElement("img"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testImage.wrappedJSObject.setAttribute("src", src); + + let loadListener = () => { + cleanupListeners(); + resolve(expectedAction === "loaded"); + }; + + let errorListener = () => { + cleanupListeners(); + resolve(expectedAction === "blocked"); + }; + + cleanupListeners = () => { + testImage.removeEventListener("load", loadListener); + testImage.removeEventListener("error", errorListener); + }; + + testImage.addEventListener("load", loadListener); + testImage.addEventListener("error", errorListener); + + document.body.appendChild(testImage); + }); + + let success = await imageLoadingPromise; + browser.runtime.sendMessage({name: "image-loading", expectedAction, success}); +} + +add_task(async function test_web_accessible_resources() { + function background() { + let gotURL; + let tabId; + + function loadFrame(url) { + return new Promise(resolve => { + browser.tabs.sendMessage(tabId, ["load-iframe", url], reply => { + resolve(reply); + }); + }); + } + + let urls = [ + [browser.extension.getURL("accessible.html"), true], + [browser.extension.getURL("accessible.html") + "?foo=bar", true], + [browser.extension.getURL("accessible.html") + "#!foo=bar", true], + [browser.extension.getURL("forbidden.html"), false], + [browser.extension.getURL("wild1.html"), true], + [browser.extension.getURL("wild2.htm"), false], + ]; + + async function runTests() { + for (let [url, shouldLoad] of urls) { + let success = await loadFrame(url); + + browser.test.assertEq(shouldLoad, success, "Load was successful"); + if (shouldLoad) { + browser.test.assertEq(url, gotURL, "Got expected url"); + } else { + browser.test.assertEq(undefined, gotURL, "Got no url"); + } + gotURL = undefined; + } + + browser.test.notifyPass("web-accessible-resources"); + } + + browser.runtime.onMessage.addListener(([msg, url], sender) => { + if (msg == "content-script-ready") { + tabId = sender.tab.id; + runTests(); + } else if (msg == "page-script") { + browser.test.assertEq(undefined, gotURL, "Should have gotten only one message"); + browser.test.assertEq("string", typeof(url), "URL should be a string"); + gotURL = url; + } + }); + + browser.test.sendMessage("ready"); + } + + function contentScript() { + browser.runtime.onMessage.addListener(([msg, url], sender, respond) => { + if (msg == "load-iframe") { + let iframe = document.createElement("iframe"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + iframe.wrappedJSObject.setAttribute("src", url); + iframe.addEventListener("load", () => { respond(true); }); + iframe.addEventListener("error", () => { respond(false); }); + document.body.appendChild(iframe); + return true; + } + }); + browser.runtime.sendMessage(["content-script-ready"]); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + "matches": ["http://example.com/"], + "js": ["content_script.js"], + "run_at": "document_idle", + }, + ], + + "web_accessible_resources": [ + "/accessible.html", + "wild*.html", + ], + }, + + background, + + files: { + "content_script.js": contentScript, + + "accessible.html": `<html><head> + <meta charset="utf-8"> + <script src="accessible.js"><\/script> + </head></html>`, + + "accessible.js": 'browser.runtime.sendMessage(["page-script", location.href]);', + + "inaccessible.html": `<html><head> + <meta charset="utf-8"> + <script src="inaccessible.js"><\/script> + </head></html>`, + + "inaccessible.js": 'browser.runtime.sendMessage(["page-script", location.href]);', + + "wild1.html": `<html><head> + <meta charset="utf-8"> + <script src="wild.js"><\/script> + </head></html>`, + + "wild2.htm": `<html><head> + <meta charset="utf-8"> + <script src="wild.js"><\/script> + </head></html>`, + + "wild.js": 'browser.runtime.sendMessage(["page-script", location.href]);', + }, + }); + + await extension.startup(); + + await extension.awaitMessage("ready"); + + let win = window.open("http://example.com/"); + + await extension.awaitFinish("web-accessible-resources"); + + win.close(); + + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources_mixed_content() { + function background() { + browser.runtime.onMessage.addListener(msg => { + if (msg.name === "image-loading") { + browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`); + browser.test.sendMessage(`image-${msg.expectedAction}`); + } else { + browser.test.sendMessage(msg); + if (msg === "accessible-script-loaded") { + browser.test.notifyPass("mixed-test"); + } + } + }); + + browser.test.sendMessage("background-ready"); + } + + function content() { + testImageLoading("http://example.com/tests/toolkit/components/extensions/test/mochitest/file_image_bad.png", "blocked"); + testImageLoading(browser.extension.getURL("image.png"), "loaded"); + + let testScriptElement = document.createElement("script"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testScriptElement.wrappedJSObject.setAttribute("src", browser.extension.getURL("test_script.js")); + document.head.appendChild(testScriptElement); + + window.addEventListener("message", event => { + browser.runtime.sendMessage(event.data); + }); + } + + function testScript() { + window.postMessage("accessible-script-loaded", "*"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "content_scripts": [{ + "matches": ["https://example.com/*/file_mixed.html"], + "run_at": "document_end", + "js": ["content_script_helper.js", "content_script.js"], + }], + "web_accessible_resources": [ + "image.png", + "test_script.js", + ], + }, + background, + files: { + "content_script_helper.js": `${testImageLoading}`, + "content_script.js": content, + "test_script.js": testScript, + "image.png": IMAGE_ARRAYBUFFER, + }, + }); + + SpecialPowers.setBoolPref("security.mixed_content.block_display_content", true); + + await Promise.all([extension.startup(), extension.awaitMessage("background-ready")]); + + let win = window.open("https://example.com/tests/toolkit/components/extensions/test/mochitest/file_mixed.html"); + + await Promise.all([ + extension.awaitMessage("image-blocked"), + extension.awaitMessage("image-loaded"), + extension.awaitMessage("accessible-script-loaded"), + ]); + await extension.awaitFinish("mixed-test"); + win.close(); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html new file mode 100644 index 0000000000..f471ef6a2f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation.html @@ -0,0 +1,611 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +if (AppConstants.platform === "android") { + SimpleTest.requestLongerTimeout(3); +} + +/* globals sendMouseEvent */ + +function backgroundScript() { + const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; + const URL = BASE + "/file_WebNavigation_page1.html"; + + const EVENTS = [ + "onTabReplaced", + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + ]; + + let expectedTabId = -1; + + function gotEvent(event, details) { + if (!details.url.startsWith(BASE)) { + return; + } + browser.test.log(`Got ${event} ${details.url} ${details.frameId} ${details.parentFrameId}`); + + if (expectedTabId == -1) { + browser.test.assertTrue(details.tabId !== undefined, "tab ID defined"); + expectedTabId = details.tabId; + } + + browser.test.assertEq(details.tabId, expectedTabId, "correct tab"); + + browser.test.sendMessage("received", {url: details.url, event}); + + if (details.url == URL) { + browser.test.assertEq(0, details.frameId, "root frame ID correct"); + browser.test.assertEq(-1, details.parentFrameId, "root parent frame ID correct"); + } else { + browser.test.assertEq(0, details.parentFrameId, "parent frame ID correct"); + browser.test.assertTrue(details.frameId != 0, "frame ID probably okay"); + } + + browser.test.assertTrue(details.frameId !== undefined, "frameId != undefined"); + browser.test.assertTrue(details.parentFrameId !== undefined, "parentFrameId != undefined"); + } + + let listeners = {}; + for (let event of EVENTS) { + listeners[event] = gotEvent.bind(null, event); + browser.webNavigation[event].addListener(listeners[event]); + } + + browser.test.sendMessage("ready"); +} + +const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; +const URL = BASE + "/file_WebNavigation_page1.html"; +const FORM_URL = URL + "?"; +const FRAME = BASE + "/file_WebNavigation_page2.html"; +const FRAME2 = BASE + "/file_WebNavigation_page3.html"; +const FRAME_PUSHSTATE = BASE + "/file_WebNavigation_page3_pushState.html"; +const REDIRECT = BASE + "/redirection.sjs"; +const REDIRECTED = BASE + "/dummy_page.html"; +const CLIENT_REDIRECT = BASE + "/file_webNavigation_clientRedirect.html"; +const CLIENT_REDIRECT_HTTPHEADER = BASE + "/file_webNavigation_clientRedirect_httpHeaders.html"; +const FRAME_CLIENT_REDIRECT = BASE + "/file_webNavigation_frameClientRedirect.html"; +const FRAME_REDIRECT = BASE + "/file_webNavigation_frameRedirect.html"; +const FRAME_MANUAL = BASE + "/file_webNavigation_manualSubframe.html"; +const FRAME_MANUAL_PAGE1 = BASE + "/file_webNavigation_manualSubframe_page1.html"; +const FRAME_MANUAL_PAGE2 = BASE + "/file_webNavigation_manualSubframe_page2.html"; +const INVALID_PAGE = "https://invalid.localhost/"; + +const REQUIRED = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", +]; + +var received = []; +var completedResolve; +var waitingURL, waitingEvent; + +function loadAndWait(win, event, url, script) { + received = []; + waitingEvent = event; + waitingURL = url; + dump(`RUN ${script}\n`); + script(); + return new Promise(resolve => { completedResolve = resolve; }); +} + +add_task(async function webnav_transitions_props() { + function backgroundScriptTransitions() { + const EVENTS = [ + "onCommitted", + "onHistoryStateUpdated", + "onReferenceFragmentUpdated", + "onCompleted", + ]; + + function gotEvent(event, details) { + browser.test.log(`Got ${event} ${details.url} ${details.transitionType} ${details.transitionQualifiers && JSON.stringify(details.transitionQualifiers)}`); + + browser.test.sendMessage("received", {url: details.url, details, event}); + } + + let listeners = {}; + for (let event of EVENTS) { + listeners[event] = gotEvent.bind(null, event); + browser.webNavigation[event].addListener(listeners[event]); + } + + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background: backgroundScriptTransitions, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + extension.onMessage("received", ({url, event, details}) => { + received.push({url, event, details}); + + if (event == waitingEvent && url == waitingURL) { + completedResolve(); + } + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + info("webnavigation extension loaded"); + + let win = window.open(); + + await loadAndWait(win, "onCompleted", URL, () => { win.location = URL; }); + + // transitionType: reload + received = []; + await loadAndWait(win, "onCompleted", URL, () => { win.location.reload(); }); + + let found = received.find((data) => (data.event == "onCommitted" && data.url == URL)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "reload", + "Got the expected 'reload' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers), + "transitionQualifiers found in the OnCommitted events"); + } + + // transitionType: auto_subframe + found = received.find((data) => (data.event == "onCommitted" && data.url == FRAME)); + + ok(found, "Got the sub-frame onCommitted event"); + + if (found) { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'auto_subframe' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers), + "transitionQualifiers found in the OnCommitted events"); + } + + // transitionType: form_submit + received = []; + await loadAndWait(win, "onCompleted", FORM_URL, () => { + win.document.querySelector("form").submit(); + }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == FORM_URL)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "form_submit", + "Got the expected 'form_submit' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers), + "transitionQualifiers found in the OnCommitted events"); + } + + // transitionQualifier: server_redirect + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { win.location = REDIRECT; }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "link", + "Got the expected 'link' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "server_redirect"), + "Got the expected 'server_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: forward_back + received = []; + await loadAndWait(win, "onCompleted", FORM_URL, () => { win.history.back(); }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == FORM_URL)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "link", + "Got the expected 'link' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "forward_back"), + "Got the expected 'forward_back' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: client_redirect + // (from meta http-equiv tag) + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { + win.location = CLIENT_REDIRECT; + }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "link", + "Got the expected 'link' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "client_redirect"), + "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: client_redirect + // (from http headers) + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { + win.location = CLIENT_REDIRECT_HTTPHEADER; + }); + + found = received.find((data) => (data.event == "onCommitted" && + data.url == CLIENT_REDIRECT_HTTPHEADER)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "link", + "Got the expected 'link' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "client_redirect"), + "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: client_redirect (sub-frame) + // (from meta http-equiv tag) + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { + win.location = FRAME_CLIENT_REDIRECT; + }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECTED)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'auto_subframe' transitionType in the OnCommitted event"); + ok(Array.isArray(found.details.transitionQualifiers) && + found.details.transitionQualifiers.find((q) => q == "client_redirect"), + "Got the expected 'client_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionQualifier: server_redirect (sub-frame) + received = []; + await loadAndWait(win, "onCompleted", REDIRECTED, () => { win.location = FRAME_REDIRECT; }); + + found = received.find((data) => (data.event == "onCommitted" && data.url == REDIRECT)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'auto_subframe' transitionType in the OnCommitted event"); + // TODO BUG 1264936: currently the server_redirect is not detected in sub-frames + // once we fix it we can test it here: + // + // ok(Array.isArray(found.details.transitionQualifiers) && + // found.details.transitionQualifiers.find((q) => q == "server_redirect"), + // "Got the expected 'server_redirect' transitionQualifiers in the OnCommitted events"); + } + + // transitionType: manual_subframe + received = []; + await loadAndWait(win, "onCompleted", FRAME_MANUAL, () => { win.location = FRAME_MANUAL; }); + found = received.find((data) => (data.event == "onCommitted" && + data.url == FRAME_MANUAL_PAGE1)); + + ok(found, "Got the onCommitted event"); + + if (found) { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'auto_subframe' transitionType in the OnCommitted event"); + } + + received = []; + await loadAndWait(win, "onCompleted", FRAME_MANUAL_PAGE2, () => { + let el = win.document.querySelector("iframe") + .contentDocument.querySelector("a"); + sendMouseEvent({type: "click"}, el, win); + }); + + found = received.find((data) => (data.event == "onCommitted" && + data.url == FRAME_MANUAL_PAGE2)); + + ok(found, "Got the onCommitted event"); + + if (found) { + if (AppConstants.MOZ_BUILD_APP === "browser") { + is(found.details.transitionType, "manual_subframe", + "Got the expected 'manual_subframe' transitionType in the OnCommitted event"); + } else { + is(found.details.transitionType, "auto_subframe", + "Got the expected 'manual_subframe' transitionType in the OnCommitted event"); + } + } + + // Test transitions properties on onHistoryStateUpdated events. + + received = []; + await loadAndWait(win, "onCompleted", FRAME2, () => { win.location = FRAME2; }); + + received = []; + await loadAndWait(win, "onHistoryStateUpdated", `${FRAME2}/pushState`, () => { + win.history.pushState({}, "History PushState", `${FRAME2}/pushState`); + }); + + found = received.find((data) => (data.event == "onHistoryStateUpdated" && + data.url == `${FRAME2}/pushState`)); + + ok(found, "Got the onHistoryStateUpdated event"); + + if (found) { + is(typeof found.details.transitionType, "string", + "Got transitionType in the onHistoryStateUpdated event"); + ok(Array.isArray(found.details.transitionQualifiers), + "Got transitionQualifiers in the onHistoryStateUpdated event"); + } + + // Test transitions properties on onReferenceFragmentUpdated events. + + received = []; + await loadAndWait(win, "onReferenceFragmentUpdated", `${FRAME2}/pushState#ref2`, () => { + win.history.pushState({}, "ReferenceFragment Update", `${FRAME2}/pushState#ref2`); + }); + + found = received.find((data) => (data.event == "onReferenceFragmentUpdated" && + data.url == `${FRAME2}/pushState#ref2`)); + + ok(found, "Got the onReferenceFragmentUpdated event"); + + if (found) { + is(typeof found.details.transitionType, "string", + "Got transitionType in the onReferenceFragmentUpdated event"); + ok(Array.isArray(found.details.transitionQualifiers), + "Got transitionQualifiers in the onReferenceFragmentUpdated event"); + } + + // cleanup phase + win.close(); + + await extension.unload(); + info("webnavigation extension unloaded"); +}); + +add_task(async function webnav_ordering() { + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background: backgroundScript, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + extension.onMessage("received", ({url, event}) => { + received.push({url, event}); + + if (event == waitingEvent && url == waitingURL) { + completedResolve(); + } + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + info("webnavigation extension loaded"); + + let win = window.open(); + + await loadAndWait(win, "onCompleted", URL, () => { win.location = URL; }); + + function checkRequired(url) { + for (let event of REQUIRED) { + let found = false; + for (let r of received) { + if (r.url == url && r.event == event) { + found = true; + } + } + ok(found, `Received event ${event} from ${url}`); + } + } + + checkRequired(URL); + checkRequired(FRAME); + + function checkBefore(action1, action2) { + function find(action) { + for (let i = 0; i < received.length; i++) { + if (received[i].url == action.url && received[i].event == action.event) { + return i; + } + } + return -1; + } + + let index1 = find(action1); + let index2 = find(action2); + ok(index1 != -1, `Action ${JSON.stringify(action1)} happened`); + ok(index2 != -1, `Action ${JSON.stringify(action2)} happened`); + ok(index1 < index2, `Action ${JSON.stringify(action1)} happened before ${JSON.stringify(action2)}`); + } + + // As required in the webNavigation API documentation: + // If a navigating frame contains subframes, its onCommitted is fired before any + // of its children's onBeforeNavigate; while onCompleted is fired after + // all of its children's onCompleted. + checkBefore({url: URL, event: "onCommitted"}, {url: FRAME, event: "onBeforeNavigate"}); + checkBefore({url: FRAME, event: "onCompleted"}, {url: URL, event: "onCompleted"}); + + // As required in the webNAvigation API documentation, check the event sequence: + // onBeforeNavigate -> onCommitted -> onDOMContentLoaded -> onCompleted + let expectedEventSequence = [ + "onBeforeNavigate", "onCommitted", "onDOMContentLoaded", "onCompleted", + ]; + + for (let i = 1; i < expectedEventSequence.length; i++) { + let after = expectedEventSequence[i]; + let before = expectedEventSequence[i - 1]; + checkBefore({url: URL, event: before}, {url: URL, event: after}); + checkBefore({url: FRAME, event: before}, {url: FRAME, event: after}); + } + + await loadAndWait(win, "onCompleted", FRAME2, () => { win.frames[0].location = FRAME2; }); + + checkRequired(FRAME2); + + let navigationSequence = [ + { + action: () => { win.frames[0].document.getElementById("elt").click(); }, + waitURL: `${FRAME2}#ref`, + expectedEvent: "onReferenceFragmentUpdated", + description: "clicked an anchor link", + }, + { + action: () => { win.frames[0].history.pushState({}, "History PushState", `${FRAME2}#ref2`); }, + waitURL: `${FRAME2}#ref2`, + expectedEvent: "onReferenceFragmentUpdated", + description: "history.pushState, same pathname, different hash", + }, + { + action: () => { win.frames[0].history.pushState({}, "History PushState", `${FRAME2}#ref2`); }, + waitURL: `${FRAME2}#ref2`, + expectedEvent: "onHistoryStateUpdated", + description: "history.pushState, same pathname, same hash", + }, + { + action: () => { + win.frames[0].history.pushState({}, "History PushState", `${FRAME2}?query_param1=value#ref2`); + }, + waitURL: `${FRAME2}?query_param1=value#ref2`, + expectedEvent: "onHistoryStateUpdated", + description: "history.pushState, same pathname, same hash, different query params", + }, + { + action: () => { + win.frames[0].history.pushState({}, "History PushState", `${FRAME2}?query_param2=value#ref3`); + }, + waitURL: `${FRAME2}?query_param2=value#ref3`, + expectedEvent: "onHistoryStateUpdated", + description: "history.pushState, same pathname, different hash, different query params", + }, + { + action: () => { win.frames[0].history.pushState(null, "History PushState", FRAME_PUSHSTATE); }, + waitURL: FRAME_PUSHSTATE, + expectedEvent: "onHistoryStateUpdated", + description: "history.pushState, different pathname", + }, + ]; + + for (let navigation of navigationSequence) { + let {expectedEvent, waitURL, action, description} = navigation; + info(`Waiting ${expectedEvent} from ${waitURL} - ${description}`); + await loadAndWait(win, expectedEvent, waitURL, action); + info(`Received ${expectedEvent} from ${waitURL} - ${description}`); + } + + for (let i = navigationSequence.length - 1; i > 0; i--) { + let {waitURL: fromURL, expectedEvent} = navigationSequence[i]; + let {waitURL} = navigationSequence[i - 1]; + info(`Waiting ${expectedEvent} from ${waitURL} - history.back() from ${fromURL} to ${waitURL}`); + await loadAndWait(win, expectedEvent, waitURL, () => { win.frames[0].history.back(); }); + info(`Received ${expectedEvent} from ${waitURL} - history.back() from ${fromURL} to ${waitURL}`); + } + + for (let i = 0; i < navigationSequence.length - 1; i++) { + let {waitURL: fromURL} = navigationSequence[i]; + let {waitURL, expectedEvent} = navigationSequence[i + 1]; + info(`Waiting ${expectedEvent} from ${waitURL} - history.forward() from ${fromURL} to ${waitURL}`); + await loadAndWait(win, expectedEvent, waitURL, () => { win.frames[0].history.forward(); }); + info(`Received ${expectedEvent} from ${waitURL} - history.forward() from ${fromURL} to ${waitURL}`); + } + + win.close(); + + await extension.unload(); + info("webnavigation extension unloaded"); +}); + +add_task(async function webnav_error_event() { + function backgroundScriptErrorEvent() { + browser.webNavigation.onErrorOccurred.addListener((details) => { + browser.test.log(`Got onErrorOccurred ${details.url} ${details.error}`); + + browser.test.sendMessage("received", {url: details.url, details, event: "onErrorOccurred"}); + }); + + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background: backgroundScriptErrorEvent, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + extension.onMessage("received", ({url, event, details}) => { + received.push({url, event, details}); + + if (event == waitingEvent && url == waitingURL) { + completedResolve(); + } + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + info("webnavigation extension loaded"); + + let win = window.open(); + + received = []; + await loadAndWait(win, "onErrorOccurred", INVALID_PAGE, () => { win.location = INVALID_PAGE; }); + + let found = received.find((data) => (data.event == "onErrorOccurred" && + data.url == INVALID_PAGE)); + + ok(found, "Got the onErrorOccurred event"); + + if (found) { + ok(found.details.error.match(/Error code [0-9]+/), + "Got the expected error string in the onErrorOccurred event"); + } + + // cleanup phase + win.close(); + + await extension.unload(); + info("webnavigation extension unloaded"); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html new file mode 100644 index 0000000000..60720e9663 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_filters.html @@ -0,0 +1,299 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_webnav_unresolved_uri_on_expected_URI_scheme() { + function background() { + let listeners = []; + + function cleanupTestListeners() { + browser.test.log(`Cleanup previous test event listeners`); + for (let {event, listener} of listeners.splice(0)) { + browser.webNavigation[event].removeListener(listener); + } + } + + function createTestListener(event, fail, urlFilter) { + return new Promise(resolve => { + function listener(details) { + let log = JSON.stringify({url: details.url, urlFilter}); + if (fail) { + browser.test.fail(`Got an unexpected ${event} on the failure listener: ${log}`); + } else { + browser.test.succeed(`Got the expected ${event} on the success listener: ${log}`); + } + + resolve(); + } + + browser.webNavigation[event].addListener(listener, {url: urlFilter}); + listeners.push({event, listener}); + }); + } + + browser.test.onMessage.addListener((msg, events, data) => { + if (msg !== "test-filters") { + return; + } + + let promises = []; + + for (let {okFilter, failFilter} of data.filters) { + for (let event of events) { + promises.push( + Promise.race([ + createTestListener(event, false, okFilter), + createTestListener(event, true, failFilter), + ])); + } + } + + Promise.all(promises).catch(e => { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + }).then(() => { + cleanupTestListeners(); + browser.test.sendMessage("test-filter-next"); + }); + + browser.test.sendMessage("test-filter-ready"); + }); + } + + let extensionData = { + manifest: { + permissions: [ + "webNavigation", + ], + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let win = window.open(); + + let testFilterScenarios = [ + { + url: "http://example.net/browser", + filters: [ + // schemes + { + okFilter: [{schemes: ["http"]}], + failFilter: [{schemes: ["https"]}], + }, + // ports + { + okFilter: [{ports: [80, 22, 443]}], + failFilter: [{ports: [81, 82, 83]}], + }, + { + okFilter: [{ports: [22, 443, [10, 80]]}], + failFilter: [{ports: [22, 23, [81, 100]]}], + }, + // multiple criteria in a single filter: + // if one of the criteria is not verified, the event should not be received. + { + okFilter: [{schemes: ["http"], ports: [80, 22, 443]}], + failFilter: [{schemes: ["http"], ports: [81, 82, 83]}], + }, + // multiple urlFilters on the same listener + // if at least one of the criteria is verified, the event should be received. + { + okFilter: [{schemes: ["https"]}, {ports: [80, 22, 443]}], + failFilter: [{schemes: ["https"]}, {ports: [81, 82, 83]}], + }, + ], + }, + { + url: "http://example.net/browser?param=1#ref", + filters: [ + // host: Equals, Contains, Prefix, Suffix + { + okFilter: [{hostEquals: "example.net"}], + failFilter: [{hostEquals: "example.com"}], + }, + { + okFilter: [{hostContains: ".example"}], + failFilter: [{hostContains: ".www"}], + }, + { + okFilter: [{hostPrefix: "example"}], + failFilter: [{hostPrefix: "www"}], + }, + { + okFilter: [{hostSuffix: "net"}], + failFilter: [{hostSuffix: "com"}], + }, + // path: Equals, Contains, Prefix, Suffix + { + okFilter: [{pathEquals: "/browser"}], + failFilter: [{pathEquals: "/"}], + }, + { + okFilter: [{pathContains: "brow"}], + failFilter: [{pathContains: "tool"}], + }, + { + okFilter: [{pathPrefix: "/bro"}], + failFilter: [{pathPrefix: "/tool"}], + }, + { + okFilter: [{pathSuffix: "wser"}], + failFilter: [{pathSuffix: "kit"}], + }, + // query: Equals, Contains, Prefix, Suffix + { + okFilter: [{queryEquals: "param=1"}], + failFilter: [{queryEquals: "wrongparam=2"}], + }, + { + okFilter: [{queryContains: "param"}], + failFilter: [{queryContains: "wrongparam"}], + }, + { + okFilter: [{queryPrefix: "param="}], + failFilter: [{queryPrefix: "wrong"}], + }, + { + okFilter: [{querySuffix: "=1"}], + failFilter: [{querySuffix: "=2"}], + }, + // urlMatches, originAndPathMatches + { + okFilter: [{urlMatches: "example.net/.*\?param=1"}], + failFilter: [{urlMatches: "example.net/.*\?wrongparam=2"}], + }, + { + okFilter: [{originAndPathMatches: "example.net\/browser"}], + failFilter: [{originAndPathMatches: "example.net/.*\?param=1"}], + }, + ], + }, + ]; + + info("WebNavigation event filters test scenarios starting..."); + + const EVENTS = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + ]; + + for (let data of testFilterScenarios) { + info(`Prepare the new test scenario: ${JSON.stringify(data)}`); + + // Bug 1589102: using plain "about:blank" crashes here in fission+debug. + win.location = "about:blank?2"; + + extension.sendMessage("test-filters", EVENTS, data); + await extension.awaitMessage("test-filter-ready"); + + info(`Loading the test url: ${data.url}`); + win.location = data.url; + + await extension.awaitMessage("test-filter-next"); + + info("Test scenario completed. Moving to the next test scenario."); + } + + info("WebNavigation event filters test onReferenceFragmentUpdated scenario starting..."); + + const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; + let url = BASE + "/file_WebNavigation_page3.html"; + + let okFilter = [{urlContains: "_page3.html"}]; + let failFilter = [{ports: [444]}]; + let data = {filters: [{okFilter, failFilter}]}; + let event = "onCompleted"; + + info(`Loading the initial test url: ${url}`); + extension.sendMessage("test-filters", [event], data); + + await extension.awaitMessage("test-filter-ready"); + win.location = url; + await extension.awaitMessage("test-filter-next"); + + event = "onReferenceFragmentUpdated"; + extension.sendMessage("test-filters", [event], data); + + await extension.awaitMessage("test-filter-ready"); + win.location = url + "#ref1"; + await extension.awaitMessage("test-filter-next"); + + info("WebNavigation event filters test onHistoryStateUpdated scenario starting..."); + + event = "onHistoryStateUpdated"; + extension.sendMessage("test-filters", [event], data); + await extension.awaitMessage("test-filter-ready"); + + win.history.pushState({}, "", BASE + "/pushState_page3.html"); + await extension.awaitMessage("test-filter-next"); + + // TODO: add additional specific tests for the other webNavigation events: + // onErrorOccurred (and onCreatedNavigationTarget on supported) + + info("WebNavigation event filters test scenarios completed."); + + await extension.unload(); + + win.close(); +}); + +add_task(async function test_webnav_empty_filter_validation_error() { + function background() { + let catchedException; + + try { + browser.webNavigation.onCompleted.addListener( + // Empty callback (not really used) + () => {}, + // Empty filter (which should raise a validation error exception). + {url: []} + ); + } catch (e) { + catchedException = e; + browser.test.log(`Got an exception`); + } + + if (catchedException && + catchedException.message.includes("Type error for parameter filters") && + catchedException.message.includes("Array requires at least 1 items; you have 0")) { + browser.test.notifyPass("webNav.emptyFilterValidationError"); + } else { + browser.test.notifyFail("webNav.emptyFilterValidationError"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webNavigation", + ], + }, + background, + }); + + await extension.startup(); + + await extension.awaitFinish("webNav.emptyFilterValidationError"); + + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html new file mode 100644 index 0000000000..6b161d8247 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webnavigation_incognito.html @@ -0,0 +1,109 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script src="/tests/SimpleTest/EventUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function webnav_test_incognito() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.allowPrivateBrowsingByDefault", false]], + }); + + // Monitor will fail if it gets any event. + let monitor = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": ["webNavigation", "*://mochi.test/*"], + }, + background() { + const EVENTS = [ + "onTabReplaced", + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + "onErrorOccurred", + "onReferenceFragmentUpdated", + "onHistoryStateUpdated", + ]; + + function onEvent(event, details) { + browser.test.fail(`not_allowed - Got ${event} ${details.url} ${details.frameId} ${details.parentFrameId}`); + } + + let listeners = {}; + for (let event of EVENTS) { + listeners[event] = onEvent.bind(null, event); + browser.webNavigation[event].addListener(listeners[event]); + } + + browser.test.onMessage.addListener(async (message, tabId) => { + // try to access the private window + await browser.test.assertRejects(browser.webNavigation.getAllFrames({tabId}), + /Invalid tab ID/, + "should not be able to get incognito frames"); + await browser.test.assertRejects(browser.webNavigation.getFrame({tabId, frameId: 0}), + /Invalid tab ID/, + "should not be able to get incognito frames"); + browser.test.notifyPass("completed"); + }); + }, + }); + + // extension loads a private window and waits for the onCompleted event. + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "webNavigation", "*://mochi.test/*"], + }, + async background() { + const BASE = "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest"; + const url = BASE + "/file_WebNavigation_page1.html"; + let window; + + browser.webNavigation.onCompleted.addListener(async (details) => { + if (details.url !== url) { + return; + } + browser.test.log(`spanning - Got onCompleted ${details.url} ${details.frameId} ${details.parentFrameId}`); + browser.test.sendMessage("completed"); + }); + browser.test.onMessage.addListener(async () => { + await browser.windows.remove(window.id); + browser.test.notifyPass("done"); + }); + window = await browser.windows.create({url, incognito: true}); + let tabs = await browser.tabs.query({active: true, windowId: window.id}); + browser.test.sendMessage("tabId", tabs[0].id); + }, + }); + + await monitor.startup(); + await extension.startup(); + + await extension.awaitMessage("completed"); + let tabId = await extension.awaitMessage("tabId"); + + await monitor.sendMessage("tab", tabId); + await monitor.awaitFinish("completed"); + + await extension.sendMessage("close"); + await extension.awaitFinish("done"); + + await extension.unload(); + await monitor.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html new file mode 100644 index 0000000000..ea99ec244a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_and_proxy_filter.html @@ -0,0 +1,134 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +<script> +"use strict"; + +// Check that the windowId and tabId filter work as expected in the webRequest +// and proxy API: +// - A non-matching windowId / tabId listener won't trigger events. +// - A matching tabId from a tab triggers the event. +// - A matching windowId from a tab triggers the event. +// (unlike test_ext_webrequest_filter.html, this also works on Android) +// - Requests from background pages can be matched with windowId and tabId -1. +add_task(async function test_filter_tabId_and_windowId() { + async function tabScript() { + let pendingExpectations = new Set(); + // Helper to detect completion of expected requests. + function watchExpected(filter, desc) { + desc += ` - ${JSON.stringify(filter)}`; + const DESC_PROXY = `${desc} (proxy)`; + const DESC_WEBREQUEST = `${desc} (webRequest)`; + pendingExpectations.add(DESC_PROXY); + pendingExpectations.add(DESC_WEBREQUEST); + browser.proxy.onRequest.addListener(() => { + pendingExpectations.delete(DESC_PROXY); + }, filter); + browser.webRequest.onBeforeRequest.addListener( + () => { + pendingExpectations.delete(DESC_WEBREQUEST); + }, + filter, + ["blocking"] + ); + } + + // Helper to detect unexpected requests. + function watchUnexpected(filter, desc) { + desc += ` - ${JSON.stringify(filter)}`; + browser.proxy.onRequest.addListener(() => { + browser.test.fail(`${desc} - unexpected proxy event`); + }, filter); + browser.webRequest.onBeforeRequest.addListener(() => { + browser.test.fail(`${desc} - unexpected webRequest event`); + }, filter); + } + + function registerExpectations(url, windowId, tabId) { + const urls = [url]; + watchUnexpected({ urls, windowId: 0 }, "non-matching windowId"); + watchUnexpected({ urls, tabId: 0 }, "non-matching tabId"); + + watchExpected({ urls, windowId }, "windowId matches"); + watchExpected({ urls, tabId }, "tabId matches"); + } + + try { + let { windowId, tabId } = await browser.runtime.sendMessage("getIds"); + browser.test.log(`Dummy tab has: tabId=${tabId} windowId=${windowId}`); + registerExpectations("http://example.com/?tab", windowId, tabId); + registerExpectations("http://example.com/?bg", -1, -1); + + // Call an API method implemented in the parent process to ensure that + // the listeners have been registered (workaround for bug 1300234). + // There is a .catch() at the end because the call is rejected on Android. + await browser.proxy.settings.get({}).catch(() => {}); + + browser.test.log("Triggering request from background page."); + await browser.runtime.sendMessage("triggerBackgroundRequest"); + + browser.test.log("Triggering request from tab."); + await fetch("http://example.com/?tab"); + + browser.test.assertEq(0, pendingExpectations.size, "got all events"); + for (let description of pendingExpectations) { + browser.test.fail(`Event not observed: ${description}`); + } + } catch (e) { + browser.test.fail(`Unexpected test failure: ${e} :: ${e.stack}`); + } + browser.test.sendMessage("testCompleted"); + } + + function background() { + browser.runtime.onMessage.addListener(async (msg, sender) => { + if (msg === "getIds") { + return { windowId: sender.tab.windowId, tabId: sender.tab.id }; + } + if (msg === "triggerBackgroundRequest") { + await fetch("http://example.com/?bg"); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "proxy", + "webRequest", + "webRequestBlocking", + "http://example.com/*", + ], + web_accessible_resources: ["tab.html"], + }, + background, + files: { + "tab.html": `<!DOCTYPE html><script src="tab.js"><\/script>`, + "tab.js": tabScript, + }, + }); + await extension.startup(); + + // bug 1641735: tabs.create / tabs.remove does not work in GeckoView unless + // `useAddonManager: "permanent"` is used, so use window.open() instead. + // + // Note that somehow window.open() unexpectedly runs null when extensions + // run in-process, i.e. extensions.webextensions.remote=false. Fortunately, + // extension tabs are automatically closed as part of extension.unload() + // below (provided that extension APIs are used in the tab - bug 1399655). + window.open(`moz-extension://${extension.uuid}/tab.html`); + + await extension.awaitMessage("testCompleted"); + await extension.unload(); +}); +</script> +</head> +<body> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html new file mode 100644 index 0000000000..f191e58b56 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_auth.html @@ -0,0 +1,182 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +// This file defines content scripts. +/* eslint-env mozilla/frame-script */ + +let baseUrl = "http://mochi.test:8888/tests/toolkit/components/passwordmgr/test/mochitest/authenticate.sjs"; +function testXHR(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.onload = resolve; + xhr.onabort = reject; + xhr.onerror = reject; + xhr.send(); + }); +} + +function getAuthHandler(result, blocking = true) { + function background(result) { + browser.webRequest.onAuthRequired.addListener((details) => { + browser.test.succeed(`authHandler.onAuthRequired called with ${details.requestId} ${details.url} result ${JSON.stringify(result)}`); + browser.test.sendMessage("onAuthRequired"); + return result; + }, {urls: ["*://mochi.test/*"]}, ["blocking"]); + browser.webRequest.onCompleted.addListener((details) => { + browser.test.succeed(`authHandler.onCompleted called with ${details.requestId} ${details.url}`); + browser.test.sendMessage("onCompleted"); + }, {urls: ["*://mochi.test/*"]}); + browser.webRequest.onErrorOccurred.addListener((details) => { + browser.test.succeed(`authHandler.onErrorOccurred called with ${details.requestId} ${details.url}`); + browser.test.sendMessage("onErrorOccurred"); + }, {urls: ["*://mochi.test/*"]}); + } + + let permissions = [ + "webRequest", + "*://mochi.test/*", + ]; + if (blocking) { + permissions.push("webRequestBlocking"); + } + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + background: `(${background})(${JSON.stringify(result)})`, + }); +} + +add_task(async function test_webRequest_auth_nonblocking_forwardAuthProvider() { + // The chrome script sets up a default auth handler on the channel, the + // extension does not return anything in the authRequred call. We should + // get the call in the extension first, then in the chrome code where we + // cancel the request to avoid dealing with the prompt dialog here. The test + // is to ensure that WebRequest calls the previous notificationCallbacks + // if the authorization is not handled by the onAuthRequired handler. + + let chromeScript = SpecialPowers.loadChromeScript(() => { + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + + let observer = channel => { + if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test")) { + return; + } + Services.obs.removeObserver(observer, "http-on-modify-request"); + channel.notificationCallbacks = { + QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor", + "nsIAuthPromptProvider", + "nsIAuthPrompt2"]), + getInterface: ChromeUtils.generateQI(["nsIAuthPromptProvider", + "nsIAuthPrompt2"]), + promptAuth(channel, level, authInfo) { + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, + getAuthPrompt(reason, iid) { + return this; + }, + asyncPromptAuth(channel, callback, context, level, authInfo) { + // We just cancel here, we're only ensuring that non-webrequest + // notificationcallbacks get called if webrequest doesn't handle it. + Promise.resolve().then(() => { + callback.onAuthCancelled(context, false); + channel.cancel(Cr.NS_BINDING_ABORTED); + sendAsyncMessage("callback-complete"); + }); + }, + }; + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + sendAsyncMessage("chrome-ready"); + }); + await chromeScript.promiseOneMessage("chrome-ready"); + let callbackComplete = chromeScript.promiseOneMessage("callback-complete"); + + let handlingExt = getAuthHandler(); + await handlingExt.startup(); + + await Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuth&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`), + ProgressEvent, + "caught rejected xhr"); + + await callbackComplete; + await handlingExt.awaitMessage("onAuthRequired"); + // We expect onErrorOccurred because the "default" authprompt above cancelled + // the auth request to avoid a dialog. + await handlingExt.awaitMessage("onErrorOccurred"); + await handlingExt.unload(); + chromeScript.destroy(); +}); + +add_task(async function test_webRequest_auth_nonblocking_forwardAuthPrompt2() { + // The chrome script sets up a default auth handler on the channel, the + // extension does not return anything in the authRequred call. We should + // get the call in the extension first, then in the chrome code where we + // cancel the request to avoid dealing with the prompt dialog here. The test + // is to ensure that WebRequest calls the previous notificationCallbacks + // if the authorization is not handled by the onAuthRequired handler. + + let chromeScript = SpecialPowers.loadChromeScript(() => { + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + + let observer = channel => { + if (!(channel instanceof Ci.nsIHttpChannel && channel.URI.host === "mochi.test")) { + return; + } + Services.obs.removeObserver(observer, "http-on-modify-request"); + channel.notificationCallbacks = { + QueryInterface: ChromeUtils.generateQI(["nsIInterfaceRequestor", + "nsIAuthPrompt2"]), + getInterface: ChromeUtils.generateQI(["nsIAuthPrompt2"]), + promptAuth(request, level, authInfo) { + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, + asyncPromptAuth(request, callback, context, level, authInfo) { + // We just cancel here, we're only ensuring that non-webrequest + // notificationcallbacks get called if webrequest doesn't handle it. + Promise.resolve().then(() => { + request.cancel(Cr.NS_BINDING_ABORTED); + sendAsyncMessage("callback-complete"); + }); + }, + }; + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + sendAsyncMessage("chrome-ready"); + }); + await chromeScript.promiseOneMessage("chrome-ready"); + let callbackComplete = chromeScript.promiseOneMessage("callback-complete"); + + let handlingExt = getAuthHandler(); + await handlingExt.startup(); + + await Assert.rejects(testXHR(`${baseUrl}?realm=auth_nonblocking_forwardAuthPromptProvider&user=auth_nonblocking_forwardAuth&pass=auth_nonblocking_forwardAuth`), + ProgressEvent, + "caught rejected xhr"); + + await callbackComplete; + await handlingExt.awaitMessage("onAuthRequired"); + // We expect onErrorOccurred because the "default" authprompt above cancelled + // the auth request to avoid a dialog. + await handlingExt.awaitMessage("onErrorOccurred"); + await handlingExt.unload(); + chromeScript.destroy(); +}); +</script> +</head> +<body> +<div id="test">Authorization Test</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html new file mode 100644 index 0000000000..86cec62fb4 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_background_events.html @@ -0,0 +1,120 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_webRequest_serviceworker_events() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.serviceWorkers.testing.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "<all_urls>", + ], + }, + background() { + let eventNames = new Set([ + "onBeforeRequest", + "onBeforeSendHeaders", + "onSendHeaders", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + "onErrorOccurred", + ]); + + function listener(name, details) { + browser.test.assertTrue(eventNames.has(name), `received ${name}`); + eventNames.delete(name); + if (name == "onCompleted") { + eventNames.delete("onErrorOccurred"); + } else if (name == "onErrorOccurred") { + eventNames.delete("onCompleted"); + } + if (eventNames.size == 0) { + browser.test.sendMessage("done"); + } + } + + for (let name of eventNames) { + browser.webRequest[name].addListener( + listener.bind(null, name), + {urls: ["https://example.com/*"]} + ); + } + }, + }); + + await extension.startup(); + let registration = await navigator.serviceWorker.register("webrequest_worker.js", {scope: "."}); + await waitForState(registration.installing, "activated"); + await extension.awaitMessage("done"); + await registration.unregister(); + await extension.unload(); +}); + +add_task(async function test_webRequest_background_events() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "<all_urls>", + ], + }, + background() { + let eventNames = new Set([ + "onBeforeRequest", + "onBeforeSendHeaders", + "onSendHeaders", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + ]); + + function listener(name, details) { + browser.test.assertTrue(eventNames.has(name), `received ${name}`); + eventNames.delete(name); + + if (eventNames.size === 0) { + browser.test.assertEq("xmlhttprequest", details.type, "correct type for fetch [see bug 1366710]"); + browser.test.assertEq(0, eventNames.size, "messages received"); + browser.test.sendMessage("done"); + } + } + + for (let name of eventNames) { + browser.webRequest[name].addListener( + listener.bind(null, name), + {urls: ["https://example.com/*"]} + ); + } + + fetch("https://example.com/example.txt").then(() => { + browser.test.succeed("Fetch succeeded."); + }, () => { + browser.test.fail("fetch received"); + browser.test.sendMessage("done"); + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html new file mode 100644 index 0000000000..742f048a8a --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_basic.html @@ -0,0 +1,446 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +function promiseWindowEvent(name, accept) { + return new Promise(resolve => { + window.addEventListener(name, function listener(event) { + if (event.data !== accept) { + return; + } + window.removeEventListener(name, listener); + resolve(event); + }); + }); +} + +if (AppConstants.platform === "android") { + SimpleTest.requestLongerTimeout(3); +} + +let extension; +add_task(async function setup() { + // Clear the image cache, since it gets in the way otherwise. + let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools); + let cache = imgTools.getImgCacheForDocument(document); + cache.clearCache(false); + function clearCache() { + ChromeUtils.import("resource://gre/modules/Services.jsm", {}).Services.cache2.clear(); + } + SpecialPowers.loadChromeScript(clearCache); + + await SpecialPowers.pushPrefEnv({ + set: [["network.http.rcwn.enabled", false]], + }); + + extension = makeExtension(); + await extension.startup(); +}); + +// expect is a set of test values used by the background script. +// +// type: type of request action +// events: optional, If defined only the events listed are expected for the +// request. If undefined, all events except onErrorOccurred +// and onBeforeRedirect are expected. Must be in order received. +// redirect: url to redirect to during onBeforeSendHeaders +// status: number expected status during onHeadersReceived, 200 default +// cancel: event in which we return cancel=true. cancelled message is sent. +// cached: expected fromCache value, default is false, checked in onCompletion +// headers: request or response headers to modify +// origin: The expected originUrl, a default origin can be passed for all files + +add_task(async function test_webRequest_links() { + let expect = { + "file_style_bad.css": { + type: "stylesheet", + events: ["onBeforeRequest", "onErrorOccurred"], + cancel: "onBeforeRequest", + }, + "file_style_redirect.css": { + type: "stylesheet", + events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"], + optional_events: ["onHeadersReceived"], + redirect: "file_style_good.css", + }, + "file_style_good.css": { + type: "stylesheet", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addStylesheet("file_style_bad.css"); + await extension.awaitMessage("cancelled"); + // we redirect to style_good which completes the test + addStylesheet("file_style_redirect.css"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_images() { + let expect = { + "file_image_bad.png": { + type: "image", + events: ["onBeforeRequest", "onErrorOccurred"], + cancel: "onBeforeRequest", + }, + "file_image_redirect.png": { + type: "image", + events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"], + optional_events: ["onHeadersReceived"], + redirect: "file_image_good.png", + }, + "file_image_good.png": { + type: "image", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addImage("file_image_bad.png"); + await extension.awaitMessage("cancelled"); + // we redirect to image_good which completes the test + addImage("file_image_redirect.png"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_scripts() { + let expect = { + "file_script_bad.js": { + type: "script", + events: ["onBeforeRequest", "onErrorOccurred"], + cancel: "onBeforeRequest", + }, + "file_script_redirect.js": { + type: "script", + events: ["onBeforeRequest", "onBeforeSendHeaders", "onBeforeRedirect"], + optional_events: ["onHeadersReceived"], + redirect: "file_script_good.js", + }, + "file_script_good.js": { + type: "script", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + let message = promiseWindowEvent("message", "test1"); + addScript("file_script_bad.js"); + await extension.awaitMessage("cancelled"); + // we redirect to script_good which completes the test + addScript("file_script_redirect.js?q=test1"); + await extension.awaitMessage("done"); + + is((await message).data, "test1", "good script ran"); +}); + +add_task(async function test_webRequest_xhr_get() { + let expect = { + "file_script_xhr.js": { + type: "script", + }, + "xhr_resource": { + status: 404, + type: "xmlhttprequest", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addScript("file_script_xhr.js"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_nonexistent() { + let expect = { + "nonexistent_script_url.js": { + status: 404, + type: "script", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addScript("nonexistent_script_url.js"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_checkCached() { + let expect = { + "file_image_good.png": { + type: "image", + cached: true, + }, + "file_script_good.js": { + type: "script", + cached: true, + }, + "file_style_good.css": { + type: "stylesheet", + cached: true, + }, + "nonexistent_script_url.js": { + status: 404, + type: "script", + cached: false, + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + let message = promiseWindowEvent("message", "test1"); + + addImage("file_image_good.png"); + addScript("file_script_good.js?q=test1"); + + is((await message).data, "test1", "good script ran"); + + addStylesheet("file_style_good.css"); + addScript("nonexistent_script_url.js"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_headers() { + let expect = { + "file_script_nonexistent.js": { + type: "script", + status: 404, + headers: { + request: { + add: { + "X-WebRequest-request": "text", + "X-WebRequest-request-binary": "binary", + }, + modify: { + "user-agent": "WebRequest", + }, + remove: [ + "referer", + ], + }, + response: { + add: { + "X-WebRequest-response": "text", + "X-WebRequest-response-binary": "binary", + }, + modify: { + "server": "WebRequest", + "content-type": "text/html; charset=utf-8", + }, + remove: [ + "connection", + ], + }, + }, + completion: "onCompleted", + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addScript("file_script_nonexistent.js"); + await extension.awaitMessage("done"); +}); + +add_task(async function test_webRequest_tabId() { + function background() { + let tab; + browser.tabs.onCreated.addListener(newTab => { + tab = newTab; + }); + + browser.test.onMessage.addListener(msg => { + if (msg === "close-tab") { + browser.tabs.remove(tab.id); + browser.test.sendMessage("tab-closed"); + } + }); + } + + let tabExt = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "web_request_tab_id@tests.mozilla.org" } }, + permissions: [ + "tabs", + ], + }, + background, + }); + await tabExt.startup(); + + let linkUrl = `file_WebRequest_page3.html?trigger=a&nocache=${Math.random()}`; + let expect = { + "file_WebRequest_page3.html": { + type: "main_frame", + }, + }; + + if (AppConstants.platform != "android") { + expect["favicon.ico"] = { + type: "image", + origin: SimpleTest.getTestFileURL(linkUrl), + cached: false, + }; + } + + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + let a = addLink(linkUrl); + a.click(); + await extension.awaitMessage("done"); + + let closed = tabExt.awaitMessage("tab-closed"); + tabExt.sendMessage("close-tab"); + await closed; + + await tabExt.unload(); +}); + +add_task(async function test_webRequest_tabId_browser() { + async function background(url) { + let tabId; + browser.test.onMessage.addListener(async (msg, expected) => { + if (msg == "create") { + let tab = await browser.tabs.create({url}); + tabId = tab.id; + return; + } + if (msg == "done") { + await browser.tabs.remove(tabId); + browser.test.sendMessage("done"); + } + }); + browser.test.sendMessage("origin", browser.runtime.getURL("/")); + } + + let pageUrl = `${SimpleTest.getTestFileURL("file_sample.html")}?nocache=${Math.random()}`; + let tabExt = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "tab_id_browser@tests.mozilla.org" } }, + permissions: [ + "tabs", + ], + }, + background: `(${background})('${pageUrl}')`, + }); + + let expect = { + "file_sample.html": { + type: "main_frame", + }, + }; + + if (AppConstants.platform != "android") { + expect["favicon.ico"] = { + type: "image", + origin: pageUrl, + cached: true, + }; + } + + await tabExt.startup(); + let origin = await tabExt.awaitMessage("origin"); + + // expecting origin == extension baseUrl + extension.sendMessage("set-expected", {expect, origin}); + await extension.awaitMessage("continue"); + + // open a tab from an extension principal + tabExt.sendMessage("create"); + await extension.awaitMessage("done"); + tabExt.sendMessage("done"); + await tabExt.awaitMessage("done"); + await tabExt.unload(); +}); + +add_task(async function test_webRequest_frames() { + let expect = { + "redirection.sjs": { + status: 302, + type: "sub_frame", + events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onBeforeRedirect"], + }, + "dummy_page.html": { + type: "sub_frame", + status: 404, + }, + "badrobot": { + type: "sub_frame", + status: 404, + events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onErrorOccurred"], + // When an url's hostname fails to be resolved, an NS_ERROR_NET_ON_RESOLVED/RESOLVING + // onError event may be fired right before the NS_ERROR_UNKNOWN_HOST + // (See Bug 1516862 for a rationale). + optional_events: ["onErrorOccurred"], + error: ["NS_ERROR_UNKNOWN_HOST", "NS_ERROR_NET_ON_RESOLVED", "NS_ERROR_NET_ON_RESOLVING"], + }, + }; + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + addFrame("redirection.sjs"); + addFrame("https://nonresolvablehostname.invalid/badrobot"); + await extension.awaitMessage("done"); +}); + +add_task(async function teardown() { + await extension.unload(); +}); + +add_task(async function test_case_preserving() { + const manifest = { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://mochi.test/", + ], + }; + + async function background() { + // This is testing if header names preserve case, + // so the case-sensitive comparison is on purpose. + function ua({url, requestHeaders}) { + if (url.endsWith("?blind-add")) { + requestHeaders.push({name: "user-agent", value: "Blind/Add"}); + return {requestHeaders}; + } + for (const header of requestHeaders) { + if (header.name === "User-Agent") { + header.value = "Case/Sensitive"; + } + } + return {requestHeaders}; + } + + await browser.webRequest.onBeforeSendHeaders.addListener(ua, {urls: ["<all_urls>"]}, ["blocking", "requestHeaders"]); + browser.test.sendMessage("ready"); + } + + const extension = ExtensionTestUtils.loadExtension({manifest, background}); + + await extension.startup(); + await extension.awaitMessage("ready"); + + const response1 = await fetch(SimpleTest.getTestFileURL("return_headers.sjs")); + const headers1 = JSON.parse(await response1.text()); + + is(headers1["user-agent"], "Case/Sensitive", "User-Agent header matched and changed."); + + const response2 = await fetch(SimpleTest.getTestFileURL("return_headers.sjs?blind-add")); + const headers2 = JSON.parse(await response2.text()); + + is(headers2["user-agent"], "Blind/Add", "User-Agent header blindly added."); + + await extension.unload(); +}); + +</script> +</head> +<body> +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html new file mode 100644 index 0000000000..89ef9f4809 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_errors.html @@ -0,0 +1,61 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for WebRequest errors</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> +<script type="text/javascript"> +"use strict"; + +async function test_connection_refused(url, expectedError) { + async function background(url, expectedError) { + browser.test.log(`background url is ${url}`); + browser.webRequest.onErrorOccurred.addListener(details => { + if (details.url != url) { + return; + } + browser.test.assertTrue(details.error.startsWith(expectedError), "error correct"); + browser.test.sendMessage("onErrorOccurred"); + }, {urls: ["<all_urls>"]}); + + let tabId; + browser.test.onMessage.addListener(async (msg, expected) => { + await browser.tabs.remove(tabId); + browser.test.sendMessage("done"); + }); + + let tab = await browser.tabs.create({url}); + tabId = tab.id; + } + + let extensionData = { + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: "connection_refused@tests.mozilla.org" } }, + permissions: ["webRequest", "tabs", "*://badchain.include-subdomains.pinning.example.com/*"], + }, + background: `(${background})("${url}", "${expectedError}")`, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("onErrorOccurred"); + extension.sendMessage("close-tab"); + await extension.awaitMessage("done"); + + await extension.unload(); +} + +add_task(function test_bad_cert() { + return test_connection_refused("https://badchain.include-subdomains.pinning.example.com/", "Unable to communicate securely with peer"); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html new file mode 100644 index 0000000000..62539b54bc --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_filter.html @@ -0,0 +1,227 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +if (AppConstants.platform === "android") { + SimpleTest.requestLongerTimeout(6); +} + +let windowData, testWindow; + +add_task(async function setup() { + let chromeScript = SpecialPowers.loadChromeScript(function() { + ChromeUtils.import("resource://gre/modules/Services.jsm", {}).Services.cache2.clear(); + }); + chromeScript.destroy(); + + testWindow = window.open("about:blank", "_blank", "width=100,height=100"); + await waitForLoad(testWindow); + + // Fetch the windowId and tabId we need to filter with WebRequest. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "tabs", + ], + }, + background() { + browser.tabs.query({currentWindow: true}).then(tabs => { + let tab = tabs.find(tab => tab.active); + let {windowId} = tab; + + browser.test.log(`current window ${windowId} tabs: ${JSON.stringify(tabs.map(tab => [tab.id, tab.url]))}`); + browser.test.sendMessage("windowData", {windowId, tabId: tab.id}); + }); + }, + }); + await extension.startup(); + windowData = await extension.awaitMessage("windowData"); + info(`window is ${JSON.stringify(windowData)}`); + await extension.unload(); +}); + +add_task(async function test_webRequest_filter_window() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // Android does not support multiple windows. + return; + } + + await SpecialPowers.pushPrefEnv({ + set: [["dom.serviceWorkers.testing.enabled", true], + ["network.http.rcwn.enabled", false]], + }); + + let events = { + "onBeforeRequest": [{urls: ["<all_urls>"], windowId: windowData.windowId}], + "onBeforeSendHeaders": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["requestHeaders"]], + "onSendHeaders": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["requestHeaders"]], + "onBeforeRedirect": [{urls: ["<all_urls>"], windowId: windowData.windowId}], + "onHeadersReceived": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["responseHeaders"]], + "onResponseStarted": [{urls: ["<all_urls>"], windowId: windowData.windowId}], + "onCompleted": [{urls: ["<all_urls>"], windowId: windowData.windowId}, ["responseHeaders"]], + "onErrorOccurred": [{urls: ["<all_urls>"], windowId: windowData.windowId}], + }; + let expect = { + "file_image_bad.png": { + optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"], + type: "main_frame", + }, + }; + + if (AppConstants.platform != "android") { + expect["favicon.ico"] = { + // These events only happen in non-e10s. See bug 1472156. + optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"], + type: "image", + origin: SimpleTest.getTestFileURL("file_image_bad.png"), + }; + } + + let extension = makeExtension(events); + await extension.startup(); + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + + // We should not get events for a new window load. + let newWindow = window.open("file_image_good.png", "_blank", "width=100,height=100"); + await waitForLoad(newWindow); + newWindow.close(); + + // We should not get background events. + let registration = await navigator.serviceWorker.register("webrequest_worker.js?test0", {scope: "."}); + await waitForState(registration.installing, "activated"); + + // We should get events for the reload. + testWindow.location = "file_image_bad.png"; + await extension.awaitMessage("done"); + + testWindow.location = "about:blank"; + await registration.unregister(); + await extension.unload(); +}); + +add_task(async function test_webRequest_filter_tab() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.serviceWorkers.testing.enabled", true]], + }); + + let img = `file_image_good.png?r=${Math.random()}`; + + let events = { + "onBeforeRequest": [{urls: ["<all_urls>"], tabId: windowData.tabId}], + "onBeforeSendHeaders": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["requestHeaders"]], + "onSendHeaders": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["requestHeaders"]], + "onBeforeRedirect": [{urls: ["<all_urls>"], tabId: windowData.tabId}], + "onHeadersReceived": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["responseHeaders"]], + "onResponseStarted": [{urls: ["<all_urls>"], tabId: windowData.tabId}], + "onCompleted": [{urls: ["<all_urls>"], tabId: windowData.tabId}, ["responseHeaders"]], + "onErrorOccurred": [{urls: ["<all_urls>"], tabId: windowData.tabId}], + }; + let expect = { + "file_image_good.png": { + // These events only happen in non-e10s. See bug 1472156. + optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders"], + type: "main_frame", + // cached: AppConstants.MOZ_BUILD_APP === "browser", + }, + }; + + if (AppConstants.platform != "android") { + // A favicon request may be initiated, and complete or be aborted. + expect["favicon.ico"] = { + optional_events: ["onBeforeRedirect", "onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted", "onCompleted", "onErrorOccurred"], + type: "image", + origin: SimpleTest.getTestFileURL(img), + }; + } + + let extension = makeExtension(events); + await extension.startup(); + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + + if (AppConstants.MOZ_BUILD_APP === "browser") { + // We should not get events for a new window load. + let newWindow = window.open(img, "_blank", "width=100,height=100"); + await waitForLoad(newWindow); + newWindow.close(); + } + + // We should not get background events. + let registration = await navigator.serviceWorker.register("webrequest_worker.js?test1", {scope: "."}); + await waitForState(registration.installing, "activated"); + + // We should get events for the reload. + testWindow.location = img; + await extension.awaitMessage("done"); + + testWindow.location = "about:blank"; + await registration.unregister(); + await extension.unload(); +}); + + +add_task(async function test_webRequest_filter_background() { + await SpecialPowers.pushPrefEnv({ + set: [["dom.serviceWorkers.testing.enabled", true]], + }); + + let events = { + "onBeforeRequest": [{urls: ["<all_urls>"], tabId: -1}], + "onBeforeSendHeaders": [{urls: ["<all_urls>"], tabId: -1}, ["requestHeaders"]], + "onSendHeaders": [{urls: ["<all_urls>"], tabId: -1}, ["requestHeaders"]], + "onBeforeRedirect": [{urls: ["<all_urls>"], tabId: -1}], + "onHeadersReceived": [{urls: ["<all_urls>"], tabId: -1}, ["responseHeaders"]], + "onResponseStarted": [{urls: ["<all_urls>"], tabId: -1}], + "onCompleted": [{urls: ["<all_urls>"], tabId: -1}, ["responseHeaders"]], + "onErrorOccurred": [{urls: ["<all_urls>"], tabId: -1}], + }; + let expect = { + "webrequest_worker.js": { + type: "script", + }, + "example.txt": { + status: 404, + events: ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", "onResponseStarted"], + optional_events: ["onCompleted", "onErrorOccurred"], + type: "xmlhttprequest", + origin: SimpleTest.getTestFileURL("webrequest_worker.js?test2"), + }, + }; + + let extension = makeExtension(events); + await extension.startup(); + extension.sendMessage("set-expected", {expect, origin: location.href}); + await extension.awaitMessage("continue"); + + // We should not get events for a window. + testWindow.location = "file_image_bad.png"; + + // We should get events for the background page. + let registration = await navigator.serviceWorker.register(SimpleTest.getTestFileURL("webrequest_worker.js?test2"), {scope: "."}); + await waitForState(registration.installing, "activated"); + await extension.awaitMessage("done"); + testWindow.location = "about:blank"; + await registration.unregister(); + + await extension.unload(); +}); + +add_task(async function teardown() { + testWindow.close(); +}); +</script> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html new file mode 100644 index 0000000000..1b26a77f2b --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_frameId.html @@ -0,0 +1,214 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>", "tabs"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.sendMessage("onBeforeRequest", details); + }, {urls: ["<all_urls>"]}, ["blocking"]); + + let tab; + browser.tabs.onCreated.addListener(newTab => { + browser.test.sendMessage("tab-created"); + tab = newTab; + }); + + browser.test.onMessage.addListener(msg => { + if (msg === "close-tab") { + browser.tabs.remove(tab.id); + browser.test.sendMessage("tab-closed"); + } + }); + }, +}; + +let expected = { + "file_simple_xhr.html": { + type: "main_frame", + toplevel: true, + }, + "file_image_good.png": { + type: "image", + toplevel: true, + origin: "file_simple_xhr.html", + }, + "example.txt": { + type: "xmlhttprequest", + toplevel: true, + origin: "file_simple_xhr.html", + }, + // sub frames will have the origin and first ancestor is the + // parent document + "file_simple_xhr_frame.html": { + type: "sub_frame", + toplevelParent: true, + origin: "file_simple_xhr.html", + parent: "file_simple_xhr.html", + }, + // a resource in a sub frame will have origin of the subframe, + // but the ancestor chain starts with the parent document + "xhr_resource": { + type: "xmlhttprequest", + origin: "file_simple_xhr_frame.html", + parent: "file_simple_xhr.html", + }, + "file_image_bad.png": { + type: "image", + depth: 2, + origin: "file_simple_xhr_frame.html", + parent: "file_simple_xhr.html", + }, + "file_simple_xhr_frame2.html": { + type: "sub_frame", + depth: 2, + origin: "file_simple_xhr_frame.html", + parent: "file_simple_xhr_frame.html", + }, + "file_image_redirect.png": { + type: "image", + depth: 2, + origin: "file_simple_xhr_frame2.html", + parent: "file_simple_xhr_frame.html", + }, + "xhr_resource_2": { + type: "xmlhttprequest", + depth: 2, + origin: "file_simple_xhr_frame2.html", + parent: "file_simple_xhr_frame.html", + }, + // This is loaded in a sandbox iframe. originUrl is not available for that, + // and requests within a sandboxed iframe will additionally have an empty + // url on their immediate parent/ancestor. + "file_simple_sandboxed_frame.html": { + type: "sub_frame", + depth: 3, + parent: "file_simple_xhr_frame2.html", + }, + "xhr_sandboxed": { + type: "xmlhttprequest", + sandboxed: true, + depth: 3, + parent: "", + }, + "file_image_great.png": { + type: "image", + sandboxed: true, + depth: 3, + parent: "", + }, + "file_simple_sandboxed_subframe.html": { + type: "sub_frame", + depth: 4, + parent: "", + }, +}; + +if (AppConstants.platform != "android") { + expected["favicon.ico"] = { + type: "image", + toplevel: true, + origin: "file_simple_xhr.html", + cached: false, + }; +} + +function checkDetails(details) { + // See bug 1471387 + if (details.originUrl == "about:newtab") { + return; + } + + let url = new URL(details.url); + let filename = url.pathname.split("/").pop(); + ok(filename in expected, `Should be expecting a request for ${filename}`); + let expect = expected[filename]; + is(expect.type, details.type, `${details.type} type matches`); + if (details.parentFrameId == -1) { + is(details.frameAncestors.length, 0, "no ancestors for main_frame requests"); + } else if (details.parentFrameId == 0) { + is(details.frameAncestors.length, 1, "one ancestors for sub_frame requests"); + } else { + ok(details.frameAncestors.length > 1, "have multiple ancestors for deep subframe requests"); + is(details.frameAncestors.length, expect.depth, "have multiple ancestors for deep subframe requests"); + } + if (details.parentFrameId > -1) { + ok(!expect.origin || details.originUrl.includes(expect.origin), "origin url is correct"); + is(details.frameAncestors[0].frameId, details.parentFrameId, "first ancestor matches request.parentFrameId"); + ok(details.frameAncestors[0].url.includes(expect.parent), "ancestor parent page correct"); + is(details.frameAncestors[details.frameAncestors.length - 1].frameId, 0, "last ancestor is always zero"); + // All our tests should be somewhere within the frame that we set topframe in the query string. That + // frame will always be the last ancestor. + ok(details.frameAncestors[details.frameAncestors.length - 1].url.includes("topframe=true"), "last ancestor is always topframe"); + } + if (expect.toplevel) { + is(details.frameId, 0, "expect load at top level"); + is(details.parentFrameId, -1, "expect top level frame to have no parent"); + } else if (details.type == "sub_frame") { + ok(details.frameId > 0, "expect sub_frame to load into a new frame"); + if (expect.toplevelParent) { + is(details.parentFrameId, 0, "expect sub_frame to have top level parent"); + is(details.frameAncestors.length, 1, "one ancestor for top sub_frame request"); + } else { + ok(details.parentFrameId > 0, "expect sub_frame to have parent"); + ok(details.frameAncestors.length > 1, "sub_frame has ancestors"); + } + expect.subframeId = details.frameId; + expect.parentId = details.parentFrameId; + } else if (expect.sandboxed) { + is(details.documentUrl, undefined, "null principal documentUrl for sandboxed request"); + } else { + // get the parent frame. + let purl = new URL(details.documentUrl); + let pfilename = purl.pathname.split("/").pop(); + let parent = expected[pfilename]; + is(details.frameId, parent.subframeId, "expect load in subframe"); + is(details.parentFrameId, parent.parentId, "expect subframe parent"); + } +} + +add_task(async function test_webRequest_main_frame() { + // Clear the image cache, since it gets in the way otherwise. + let imgTools = SpecialPowers.Cc["@mozilla.org/image/tools;1"].getService(SpecialPowers.Ci.imgITools); + let cache = imgTools.getImgCacheForDocument(document); + cache.clearCache(false); + function clearCache() { + ChromeUtils.import("resource://gre/modules/Services.jsm", {}).Services.cache2.clear(); + } + SpecialPowers.loadChromeScript(clearCache); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let a = addLink(`file_simple_xhr.html?topframe=true&nocache=${Math.random()}`); + a.click(); + + for (let i = 0; i < Object.keys(expected).length; i++) { + checkDetails(await extension.awaitMessage("onBeforeRequest")); + } + + await extension.awaitMessage("tab-created"); + extension.sendMessage("close-tab"); + await extension.awaitMessage("tab-closed"); + + await extension.unload(); +}); +</script> +</head> +<body> +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html new file mode 100644 index 0000000000..51ffc1e4f6 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_hsts.html @@ -0,0 +1,223 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <script type="text/javascript" src="head_webrequest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +<script> +"use strict"; + +function getExtension() { + async function background() { + let expect; + let urls = ["*://*.example.org/tests/*"]; + browser.webRequest.onBeforeRequest.addListener(details => { + browser.test.assertEq(expect.shift(), "onBeforeRequest"); + }, {urls}, ["blocking"]); + browser.webRequest.onBeforeSendHeaders.addListener(details => { + browser.test.assertEq(expect.shift(), "onBeforeSendHeaders"); + }, {urls}, ["blocking", "requestHeaders"]); + browser.webRequest.onSendHeaders.addListener(details => { + browser.test.assertEq(expect.shift(), "onSendHeaders"); + }, {urls}, ["requestHeaders"]); + + async function testSecurityInfo(details, options) { + let securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, options); + browser.test.assertTrue(securityInfo && securityInfo.state == "secure", + "security info reflects https"); + + if (options.certificateChain) { + // Some of the tests here only produce a single cert in the chain. + browser.test.assertTrue(securityInfo.certificates.length >= 1, "have certificate chain"); + } else { + browser.test.assertTrue(securityInfo.certificates.length == 1, "no certificate chain"); + } + let cert = securityInfo.certificates[0]; + let now = Date.now(); + browser.test.assertTrue(Number.isInteger(cert.validity.start), "cert start is integer"); + browser.test.assertTrue(Number.isInteger(cert.validity.end), "cert end is integer"); + browser.test.assertTrue(cert.validity.start < now, "cert start validity is correct"); + browser.test.assertTrue(now < cert.validity.end, "cert end validity is correct"); + if (options.rawDER) { + for (let cert of securityInfo.certificates) { + browser.test.assertTrue(!!cert.rawDER.length, "have rawDER"); + } + } + } + + browser.webRequest.onHeadersReceived.addListener(async (details) => { + browser.test.assertEq(expect.shift(), "onHeadersReceived"); + + // We exepect all requests to have been upgraded at this point. + browser.test.assertTrue(details.url.startsWith("https"), "connection is https"); + await testSecurityInfo(details, {}); + await testSecurityInfo(details, {certificateChain: true}); + await testSecurityInfo(details, {rawDER: true}); + await testSecurityInfo(details, {certificateChain: true, rawDER: true}); + + let headers = details.responseHeaders || []; + for (let header of headers) { + if (header.name.toLowerCase() === "strict-transport-security") { + return; + } + } + + headers.push({ + name: "Strict-Transport-Security", + value: "max-age=31536000000", + }); + return {responseHeaders: headers}; + }, {urls}, ["blocking", "responseHeaders"]); + browser.webRequest.onBeforeRedirect.addListener(details => { + browser.test.assertEq(expect.shift(), "onBeforeRedirect"); + }, {urls}); + browser.webRequest.onResponseStarted.addListener(details => { + browser.test.assertEq(expect.shift(), "onResponseStarted"); + }, {urls}); + browser.webRequest.onCompleted.addListener(details => { + browser.test.assertEq(expect.shift(), "onCompleted"); + browser.test.sendMessage("onCompleted", details.url); + }, {urls}); + browser.webRequest.onErrorOccurred.addListener(details => { + browser.test.notifyFail(`onErrorOccurred ${JSON.stringify(details)}`); + }, {urls}); + + async function onUpdated(tabId, tabInfo, tab) { + if (tabInfo.status !== "complete" || tab.url === "about:blank") { + return; + } + browser.tabs.remove(tabId); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.test.sendMessage("tabs-done", tab.url); + } + browser.test.onMessage.addListener((url, expected) => { + expect = expected; + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.create({url}); + }); + } + + let manifest = { + "permissions": [ + "tabs", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }; + return ExtensionTestUtils.loadExtension({ + manifest, + background, + }); +} + +// This test makes a request against a server that redirects with a 302. +add_task(async function test_hsts_request() { + const testPath = "example.org/tests/toolkit/components/extensions/test/mochitest"; + + let extension = getExtension(); + await extension.startup(); + + // simple redirect + let sample = "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html"; + extension.sendMessage( + `https://${testPath}/redirect_auto.sjs?redirect_uri=${sample}`, + ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", + "onHeadersReceived", "onBeforeRedirect", "onBeforeRequest", + "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", + "onResponseStarted", "onCompleted"]); + // redirect_auto adds a query string + ok((await extension.awaitMessage("tabs-done")).startsWith(sample), "redirection ok"); + ok((await extension.awaitMessage("onCompleted")).startsWith(sample), "redirection ok"); + + // priming hsts + extension.sendMessage( + `https://${testPath}/hsts.sjs`, + ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", + "onHeadersReceived", "onResponseStarted", "onCompleted"]); + is(await extension.awaitMessage("tabs-done"), + "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs", + "hsts primed"); + is(await extension.awaitMessage("onCompleted"), + "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs"); + + // test upgrade + extension.sendMessage( + `http://${testPath}/hsts.sjs`, + ["onBeforeRequest", "onBeforeRedirect", "onBeforeRequest", + "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", + "onResponseStarted", "onCompleted"]); + is(await extension.awaitMessage("tabs-done"), + "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs", + "hsts upgraded"); + is(await extension.awaitMessage("onCompleted"), + "https://example.org/tests/toolkit/components/extensions/test/mochitest/hsts.sjs"); + + await extension.unload(); +}); + +// This test makes a priming request and adds the STS header, then tests the upgrade. +add_task(async function test_hsts_header() { + const testPath = "test1.example.org/tests/toolkit/components/extensions/test/mochitest"; + + let extension = getExtension(); + await extension.startup(); + + // priming hsts, this time there is no STS header, onHeadersReceived adds it. + let completed = extension.awaitMessage("onCompleted"); + let tabdone = extension.awaitMessage("tabs-done"); + extension.sendMessage( + `https://${testPath}/file_sample.html`, + ["onBeforeRequest", "onBeforeSendHeaders", "onSendHeaders", + "onHeadersReceived", "onResponseStarted", "onCompleted"]); + is(await tabdone, `https://${testPath}/file_sample.html`, "priming request done"); + is(await completed, `https://${testPath}/file_sample.html`, "priming request done"); + + // test upgrade from http to https due to onHeadersReceived adding STS header + completed = extension.awaitMessage("onCompleted"); + tabdone = extension.awaitMessage("tabs-done"); + extension.sendMessage( + `http://${testPath}/file_sample.html`, + ["onBeforeRequest", "onBeforeRedirect", "onBeforeRequest", + "onBeforeSendHeaders", "onSendHeaders", "onHeadersReceived", + "onResponseStarted", "onCompleted"]); + is(await tabdone, `https://${testPath}/file_sample.html`, "hsts upgraded"); + is(await completed, `https://${testPath}/file_sample.html`, "request upgraded"); + + await extension.unload(); +}); + +add_task(async function test_nonBlocking_securityInfo() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + "permissions": [ + "webRequest", + "<all_urls>", + ], + }, + async background() { + let tab; + browser.webRequest.onHeadersReceived.addListener(async (details) => { + let securityInfo = await browser.webRequest.getSecurityInfo(details.requestId, {}); + browser.test.assertTrue(!securityInfo, "securityInfo undefined on http request"); + browser.tabs.remove(tab.id); + browser.test.notifyPass("success"); + }, {urls: ["<all_urls>"], types: ["main_frame"]}); + tab = await browser.tabs.create({url: "https://example.org/tests/toolkit/components/extensions/test/mochitest/file_sample.html"}); + }, + }); + await extension.startup(); + + await extension.awaitFinish("success"); + await extension.unload(); +}); +</script> +</head> +<body> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html new file mode 100644 index 0000000000..457d0508b7 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_bypass_cors.html @@ -0,0 +1,70 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Bug 1450965: Skip Cors Check for Early WebExtention Redirects </title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* Description of the test: + * We try to Check if a WebExtention can redirect a request and bypass CORS + * We're redirecting a fetch request in onBeforeRequest + * which should not be blocked, even though we do not have + * the CORS information yet. + */ + +const WIN_URL = + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_redirect_cors_bypass.html"; + + +add_task(async function test_webRequest_redirect_cors_bypass() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }, + background() { + browser.webRequest.onBeforeRequest.addListener((details) => { + if (details.url.includes("file_cors_blocked.txt")) { + // File_cors_blocked does not need to exist, because we're redirecting anyway. + const testPath = "example.org/tests/toolkit/components/extensions/test/mochitest"; + let redirectUrl = `https://${testPath}/file_sample.txt`; + + // If the WebExtion cant bypass CORS, the fetch will throw a CORS-Exception + // because we do not have the CORS header yet for 'file-cors-blocked.txt' + return {redirectUrl}; + } + }, {urls: ["<all_urls>"]}, ["blocking"]); + }, + + }); + + await extension.startup(); + let win = window.open(WIN_URL); + // Creating a message channel to the new tab. + const channel = new BroadcastChannel("test_bus"); + await new Promise((resolve, reject) => { + channel.onmessage = async function(fetch_result) { + // Fetch result data will either be the text content of file_sample.txt -> 'Sample' + // or a network-Error. + // In case it's 'Sample' the redirect did happen correctly. + ok(fetch_result.data == "Sample", "Cors was Bypassed"); + win.close(); + await extension.unload(); + resolve(); + }; + }); +}); + +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html new file mode 100644 index 0000000000..5d58549c46 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_redirect_data_uri.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Bug 1434357: Allow Web Request API to redirect to data: URI</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* Description of the test: + * We load a *.js file which gets redirected to a data: URI. + * Since there is no good way to communicate loaded data: URI scripts + * we use updating a divContainer as a detour to verify the data: URI + * script has loaded. + */ + +const WIN_URL = + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_redirect_data_uri.html"; + +add_task(async function test_webRequest_redirect_data_uri() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "*://mochi.test/tests/*", + ], + content_scripts: [{ + matches: ["*://mochi.test/tests/*/file_redirect_data_uri.html"], + run_at: "document_end", + js: ["content_script.js"], + "all_frames": true, + }], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener((details) => { + if (details.url.includes("dummy_non_existend_file.js")) { + let redirectUrl = + "data:text/javascript,document.getElementById('testdiv').textContent='loaded'"; + return {redirectUrl}; + } + }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]); + }, + + files: { + "content_script.js": function() { + let scriptEl = document.createElement("script"); + // please note that dummy_non_existend_file.js file does not really need + // to exist because we redirect the load within onBeforeRequest(). + scriptEl.src = "dummy_non_existend_file.js"; + document.body.appendChild(scriptEl); + + scriptEl.onload = function() { + let divContent = document.getElementById("testdiv").textContent; + browser.test.assertEq(divContent, "loaded", + "redirect to data: URI allowed"); + browser.test.sendMessage("finished"); + }; + scriptEl.onerror = function() { + browser.test.fail("script load failure"); + browser.test.sendMessage("finished"); + }; + }, + }, + }); + + await extension.startup(); + let win = window.open(WIN_URL); + await extension.awaitMessage("finished"); + win.close(); + await extension.unload(); +}); + +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html new file mode 100644 index 0000000000..3d24f5a64d --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upgrade.html @@ -0,0 +1,89 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for simple WebExtension</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +add_task(async function test_webRequest_upgrade() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "*://mochi.test/tests/*", + ], + }, + background() { + browser.webRequest.onSendHeaders.addListener((details) => { + // At this point, the request should have been upgraded. + browser.test.assertTrue(details.url.startsWith("https:"), "request is upgraded"); + browser.test.assertTrue(details.url.includes("file_sample"), "redirect after upgrade worked"); + browser.test.sendMessage("finished"); + }, {urls: ["*://mochi.test/tests/*"]}); + + browser.webRequest.onBeforeRequest.addListener((details) => { + browser.test.log(`onBeforeRequest ${details.requestId} ${details.url}`); + let url = new URL(details.url); + if (url.protocol == "http:") { + return {upgradeToSecure: true}; + } + // After the channel is initially upgraded, we get another onBeforeRequest + // call. Here we can redirect again to a new url. + if (details.url.includes("file_mixed.html")) { + let redirectUrl = new URL("file_sample.html", details.url).href; + return {redirectUrl}; + } + }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]); + }, + }); + + await extension.startup(); + let win = window.open("http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_mixed.html"); + await extension.awaitMessage("finished"); + win.close(); + await extension.unload(); +}); + +add_task(async function test_webRequest_redirect_wins() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "*://mochi.test/tests/*", + ], + }, + background() { + browser.webRequest.onSendHeaders.addListener((details) => { + // At this point, the request should have been redirected instead of upgraded. + browser.test.assertTrue(details.url.includes("file_sample"), "request was redirected"); + browser.test.sendMessage("finished"); + }, {urls: ["*://mochi.test/tests/*"]}); + + browser.webRequest.onBeforeRequest.addListener((details) => { + if (details.url.includes("file_mixed.html")) { + let redirectUrl = new URL("file_sample.html", details.url).href; + return {upgradeToSecure: true, redirectUrl}; + } + }, {urls: ["*://mochi.test/tests/*"]}, ["blocking"]); + }, + }); + + await extension.startup(); + let win = window.open("http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_mixed.html"); + await extension.awaitMessage("finished"); + win.close(); + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html new file mode 100644 index 0000000000..f4f2e66955 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_webrequest_upload.html @@ -0,0 +1,212 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<form method="post" + action="file_WebRequest_page3.html?trigger=form" + target="_blank" + enctype="multipart/form-data" + > +<input type="text" name=""special" ch�rs" value="sp�cial"> +<input type="file" name="testFile"> +<input type="file" name="emptyFile"> +<input type="text" name="textInput1" value="value1"> +</form> + +<form method="post" + action="file_WebRequest_page3.html?trigger=form" + target="_blank" + enctype="multipart/form-data" + > +<input type="text" name="textInput2" value="value2"> +<input type="file" name="testFile"> +<input type="file" name="emptyFile"> +</form> + +</form> +<form method="post" + action="file_WebRequest_page3.html?trigger=form" + target="_blank" + > +<input type="text" name="textInput" value="value1"> +<input type="text" name="textInput" value="value2"> +</form> +<script> +"use strict"; + +let files, testFile, blob, file, uploads; +add_task(async function test_setup() { + files = await new Promise(resolve => { + SpecialPowers.createFiles([{name: "testFile.pdf", data: "Not really a PDF file :)", "type": "application/x-pdf"}], (result) => { + resolve(result); + }); + }); + testFile = files[0]; + blob = { + name: "blobAsFile", + content: new Blob(["A blob sent as a file"], {type: "text/csv"}), + fileName: "blobAsFile.csv", + }; + file = { + name: "testFile", + fileName: testFile.name, + }; + uploads = { + [blob.name]: blob, + [file.name]: file, + }; +}); + +function background() { + const FILTERS = {urls: ["<all_urls>"]}; + + function onUpload(details) { + let url = new URL(details.url); + let upload = url.searchParams.get("upload"); + if (!upload) { + return; + } + + let requestBody = details.requestBody; + browser.test.log(`onBeforeRequest upload: ${details.url} ${JSON.stringify(details.requestBody)}`); + browser.test.assertTrue(!!requestBody, `Intercepted upload ${details.url} #${details.requestId} ${upload} have a requestBody`); + if (!requestBody) { + return; + } + let byteLength = parseInt(upload, 10); + if (byteLength) { + browser.test.assertTrue(!!requestBody.raw, `Binary upload ${details.url} #${details.requestId} ${upload} have a raw attribute`); + browser.test.assertEq(byteLength, requestBody.raw && requestBody.raw.map(r => r.bytes ? r.bytes.byteLength : 0).reduce((a, b) => a + b), `Binary upload size matches`); + return; + } + if ("raw" in requestBody) { + browser.test.assertEq(upload, JSON.stringify(requestBody.raw).replace(/(\bfile: ")[^"]+/, "$1<file>"), `Upload ${details.url} #${details.requestId} matches raw data`); + } else { + browser.test.assertEq(upload, JSON.stringify(requestBody.formData), `Upload ${details.url} #${details.requestId} matches form data.`); + } + } + + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${details.requestId} ${details.url}`); + // See bug 1471387 + if (details.url.endsWith("/favicon.ico") || details.originUrl == "about:newtab") { + return; + } + + browser.test.sendMessage("done"); + }, + FILTERS); + + let onBeforeRequest = details => { + browser.test.log(`${name} ${details.requestId} ${details.url}`); + // See bug 1471387 + if (details.url.endsWith("/favicon.ico") || details.originUrl == "about:newtab") { + return; + } + + onUpload(details); + }; + + browser.webRequest.onBeforeRequest.addListener( + onBeforeRequest, FILTERS, ["requestBody"]); + + let tab; + browser.tabs.onCreated.addListener(newTab => { + tab = newTab; + }); + + browser.test.onMessage.addListener(msg => { + if (msg === "close-tab") { + browser.tabs.remove(tab.id); + browser.test.sendMessage("tab-closed"); + } + }); +} + +add_task(async function test_xhr_forms() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "tabs", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }, + background, + }); + + await extension.startup(); + + async function doneAndTabClosed() { + await extension.awaitMessage("done"); + let closed = extension.awaitMessage("tab-closed"); + extension.sendMessage("close-tab"); + await closed; + } + + for (let form of document.forms) { + if (file.name in form.elements) { + SpecialPowers.wrap(form.elements[file.name]).mozSetFileArray(files); + } + let action = new URL(form.action); + let formData = new FormData(form); + let webRequestFD = {}; + + let updateActionURL = () => { + for (let name of formData.keys()) { + webRequestFD[name] = name in uploads ? [uploads[name].fileName] : formData.getAll(name); + } + action.searchParams.set("upload", JSON.stringify(webRequestFD)); + action.searchParams.set("enctype", form.enctype); + }; + + updateActionURL(); + + form.action = action; + form.submit(); + await doneAndTabClosed(); + + if (form.enctype !== "multipart/form-data") { + continue; + } + + let post = (data) => { + let xhr = new XMLHttpRequest(); + action.searchParams.set("xhr", "1"); + xhr.open("POST", action.href); + xhr.send(data); + action.searchParams.delete("xhr"); + return doneAndTabClosed(); + }; + + formData.append(blob.name, blob.content, blob.fileName); + formData.append("formDataField", "some value"); + updateActionURL(); + await post(formData); + + action.searchParams.set("upload", JSON.stringify([{file: "<file>"}])); + await post(testFile); + + action.searchParams.set("upload", `${blob.content.size} bytes`); + await post(blob.content); + + let byteLength = 16; + action.searchParams.set("upload", `${byteLength} bytes`); + await post(new ArrayBuffer(byteLength)); + } + + await extension.unload(); +}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html new file mode 100644 index 0000000000..53b19d0ead --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_ext_window_postMessage.html @@ -0,0 +1,104 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Test for content script</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_postMessage() { + let extensionData = { + manifest: { + content_scripts: [ + { + "matches": ["http://mochi.test/*/file_sample.html"], + "js": ["content_script.js"], + "run_at": "document_start", + "all_frames": true, + }, + ], + + web_accessible_resources: ["iframe.html"], + }, + + background() { + browser.test.sendMessage("iframe-url", browser.runtime.getURL("iframe.html")); + }, + + files: { + "content_script.js": function() { + window.addEventListener("message", event => { + if (event.data == "ping") { + event.source.postMessage({pong: location.href}, + event.origin); + } + }); + }, + + "iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="content_script.js"><\/script> + </head> + </html>`, + }, + }; + + let createIframe = url => { + let iframe = document.createElement("iframe"); + return new Promise(resolve => { + iframe.src = url; + iframe.onload = resolve; + document.body.appendChild(iframe); + }).then(() => { + return iframe; + }); + }; + + let awaitMessage = () => { + return new Promise(resolve => { + let listener = event => { + if (event.data.pong) { + window.removeEventListener("message", listener); + resolve(event.data); + } + }; + window.addEventListener("message", listener); + }); + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let iframeURL = await extension.awaitMessage("iframe-url"); + let testURL = SimpleTest.getTestFileURL("file_sample.html"); + + for (let url of [iframeURL, testURL]) { + info(`Testing URL ${url}`); + + let iframe = await createIframe(url); + + iframe.contentWindow.postMessage( + "ping", url); + + let pong = await awaitMessage(); + is(pong.pong, url, "Got expected pong"); + + iframe.remove(); + } + + await extension.unload(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html new file mode 100644 index 0000000000..6f46fa8eea --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_verify_non_remote_mode.html @@ -0,0 +1,31 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Verify non-remote mode</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> +"use strict"; +add_task(async function verify_extensions_in_parent_process() { + // This test ensures we are running with the proper settings. + const { WebExtensionPolicy } = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services); + SimpleTest.ok(!WebExtensionPolicy.useRemoteWebExtensions, "extensions running in-process"); + + let chromeScript = SpecialPowers.loadChromeScript(() => { + const { WebExtensionPolicy } = Cu.getGlobalForObject(Services); + Assert.ok(WebExtensionPolicy.isExtensionProcess, "parent is extension process"); + this.sendAsyncMessage("checks_done"); + }); + await chromeScript.promiseOneMessage("checks_done"); + chromeScript.destroy(); +}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html b/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html new file mode 100644 index 0000000000..2be0e19179 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/test_verify_remote_mode.html @@ -0,0 +1,22 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Verify remote mode</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <script type="text/javascript" src="head.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/> +</head> +<body> + +<script type="text/javascript"> + "use strict"; + // This test ensures we are running with the proper settings. + const {WebExtensionPolicy} = SpecialPowers.Cu.getGlobalForObject(SpecialPowers.Services); + SimpleTest.ok(WebExtensionPolicy.useRemoteWebExtensions, "extensions running remote"); + SimpleTest.ok(!WebExtensionPolicy.isExtensionProcess, "testing from remote process"); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js new file mode 100644 index 0000000000..6a44fcac2e --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_chromeworker.js @@ -0,0 +1,9 @@ +"use strict"; + +/* eslint-env worker */ + +onmessage = function(event) { + fetch("https://example.com/example.txt").then(() => { + postMessage("Done!"); + }); +}; diff --git a/toolkit/components/extensions/test/mochitest/webrequest_test.jsm b/toolkit/components/extensions/test/mochitest/webrequest_test.jsm new file mode 100644 index 0000000000..6fc2fe3d7f --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_test.jsm @@ -0,0 +1,22 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["webrequest_test"]; + +Cu.importGlobalProperties(["fetch"]); + +var webrequest_test = { + testFetch(url) { + return fetch(url); + }, + + testXHR(url) { + return new Promise(resolve => { + let xhr = new XMLHttpRequest(); + xhr.open("HEAD", url); + xhr.onload = () => { + resolve(); + }; + xhr.send(); + }); + }, +}; diff --git a/toolkit/components/extensions/test/mochitest/webrequest_worker.js b/toolkit/components/extensions/test/mochitest/webrequest_worker.js new file mode 100644 index 0000000000..dcffd08578 --- /dev/null +++ b/toolkit/components/extensions/test/mochitest/webrequest_worker.js @@ -0,0 +1,3 @@ +"use strict"; + +fetch("https://example.com/example.txt"); diff --git a/toolkit/components/extensions/test/xpcshell/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..3622fff4f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = { + env: { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + webextensions: true, + }, +}; diff --git a/toolkit/components/extensions/test/xpcshell/data/dummy_page.html b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html new file mode 100644 index 0000000000..c1c9a4e043 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> + +<html> +<body> +<p>Page</p> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt diff --git a/toolkit/components/extensions/test/xpcshell/data/file download.txt b/toolkit/components/extensions/test/xpcshell/data/file download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html new file mode 100644 index 0000000000..b2cf48f9e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html @@ -0,0 +1,25 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<link rel="stylesheet" href="file_style_good.css"> +<link rel="stylesheet" href="file_style_bad.css"> +<link rel="stylesheet" href="file_style_redirect.css"> +</head> +<body> + +<div class="test">Sample text</div> + +<img id="img_good" src="file_image_good.png"> +<img id="img_bad" src="file_image_bad.png"> +<img id="img_redirect" src="file_image_redirect.png"> + +<script src="file_script_good.js"></script> +<script src="file_script_bad.js"></script> +<script src="file_script_redirect.js"></script> + +<script src="nonexistent_script_url.js"></script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html new file mode 100644 index 0000000000..f6b5142c4d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script src="http://example.org/data/file_WebRequest_permission_original.js"></script> +<script> +"use strict"; + +window.parent.postMessage({ + page: "original", + script: window.testScript, +}, "*"); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js new file mode 100644 index 0000000000..2981108b64 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "original"; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html new file mode 100644 index 0000000000..0979593f7b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script src="http://example.org/data/file_WebRequest_permission_original.js"></script> +<script> +"use strict"; + +window.parent.postMessage({ + page: "redirected", + script: window.testScript, +}, "*"); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js new file mode 100644 index 0000000000..06fd42aa40 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "redirected"; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html b/toolkit/components/extensions/test/xpcshell/data/file_csp.html new file mode 100644 index 0000000000..9f5cf92f5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> +<img id="bad-image" src="http://example.org/data/file_image_bad.png"> +<script id="bad-script" src="http://example.org/data/file_script_bad.js"></script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ new file mode 100644 index 0000000000..4c6fa3c26a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ @@ -0,0 +1 @@ +Content-Security-Policy: default-src 'self' diff --git a/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html new file mode 100644 index 0000000000..c74dec5f5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> +<script src="http://example.net/intercept_by_webRequest.js"></script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_open.html b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html new file mode 100644 index 0000000000..dae5e90667 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + + <iframe id="iframe"></iframe> + + <script type="text/javascript"> + "use strict"; + addEventListener("load", () => { + let iframe = document.getElementById("iframe"); + let doc = iframe.contentDocument; + doc.open("text/html"); + doc.write("Hello."); + doc.close(); + }, {once: true}); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_write.html b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html new file mode 100644 index 0000000000..fbae3d6d76 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html @@ -0,0 +1,35 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + <iframe id="iframe"></iframe> + + <script type="text/javascript"> + "use strict"; + addEventListener("load", () => { + // Send a heap-minimize observer notification so our script cache is + // cleared, and our content script isn't available for synchronous + // insertion. + window.dispatchEvent(new CustomEvent("MozHeapMinimize")); + + let iframe = document.getElementById("iframe"); + let doc = iframe.contentDocument; + doc.open("text/html"); + // We need to do two writes here. The first creates the document element, + // which normally triggers parser blocking. The second triggers the + // creation of the element we're about to query for, which would normally + // happen asynchronously if the parser were blocked. + doc.write("<div id=meh>"); + doc.write("<div id=beer></div>"); + + let elem = doc.getElementById("beer"); + top.postMessage(elem instanceof HTMLDivElement ? "ok" : "fail", + "*"); + + doc.close(); + }, {once: true}); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.html b/toolkit/components/extensions/test/xpcshell/data/file_download.html new file mode 100644 index 0000000000..d970c63259 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div>Download HTML File</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.txt b/toolkit/components/extensions/test/xpcshell/data/file_download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html new file mode 100644 index 0000000000..0cd68be586 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Iframe document</title> +</head> +<body> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_good.png b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html new file mode 100644 index 0000000000..387b5285f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +addEventListener("message", async function(event) { + const url = new URL("/return_headers.sjs", location).href; + + const webpageFetchResult = await fetch(url).then(res => res.json()); + const webpageXhrResult = await new Promise(resolve => { + const req = new XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", () => resolve(JSON.parse(req.responseText)), + {once: true}); + req.addEventListener("error", () => resolve({error: "webpage xhr failed to complete"}), + {once: true}); + req.send(); + }); + + postMessage({ + type: "testPageGlobals", + webpageFetchResult, + webpageXhrResult, + }, "*"); +}, {once: true}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html new file mode 100644 index 0000000000..6f1bb4648b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html @@ -0,0 +1,61 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +/* globals privilegedFetch, privilegedXHR */ +/* eslint-disable mozilla/balanced-listeners */ + +addEventListener("message", function rcv(event) { + removeEventListener("message", rcv, false); + + function assertTrue(condition, description) { + postMessage({msg: "assertTrue", condition, description}, "*"); + } + + function assertThrows(func, expectedError, msg) { + try { + func(); + } catch (e) { + assertTrue(expectedError.test(e), msg + ": threw " + e); + return; + } + + assertTrue(false, "Function did not throw, " + + "expected error should have matched " + expectedError); + } + + function passListener() { + assertTrue(true, "Content XHR has no elevated privileges"); + postMessage({"msg": "finish"}, "*"); + } + + function failListener() { + assertTrue(false, "Content XHR has no elevated privileges"); + postMessage({"msg": "finish"}, "*"); + } + + assertThrows(function() { new privilegedXHR(); }, + /Permission denied to access object/, + "Content should not be allowed to construct a privileged XHR constructor"); + + assertThrows(function() { new privilegedFetch(); }, + / is not a constructor/, + "Content should not be allowed to construct a privileged fetch() constructor"); + + let req = new XMLHttpRequest(); + req.addEventListener("load", failListener); + req.addEventListener("error", passListener); + req.open("GET", "http://example.org/example.txt"); + req.send(); +}, false); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html new file mode 100644 index 0000000000..258f7058d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + <script type="text/javascript"> + "use strict"; + throw new Error(`WebExt Privilege Escalation: typeof(browser) = ${typeof(browser)}`); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample.html b/toolkit/components/extensions/test/xpcshell/data/file_sample.html new file mode 100644 index 0000000000..a20e49a1f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html new file mode 100644 index 0000000000..9f5c5d5a6a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="registered-extension-url-style">Registered Extension URL style</div> +<div id="registered-extension-text-style">Registered Extension Text style</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script.html b/toolkit/components/extensions/test/xpcshell/data/file_script.html new file mode 100644 index 0000000000..8d192b7d8e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<script type="application/javascript" src="file_script_good.js"></script> +<script type="application/javascript" src="file_script_bad.js"></script> +</head> +<body> + +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js new file mode 100644 index 0000000000..ff4572865b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js @@ -0,0 +1,12 @@ +"use strict"; + +window.failure = true; +window.addEventListener( + "load", + () => { + let el = document.createElement("div"); + el.setAttribute("id", "bad"); + document.body.appendChild(el); + }, + { once: true } +); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_good.js b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js new file mode 100644 index 0000000000..bf47fb36d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js @@ -0,0 +1,12 @@ +"use strict"; + +window.success = window.success ? window.success + 1 : 1; +window.addEventListener( + "load", + () => { + let el = document.createElement("div"); + el.setAttribute("id", "good"); + document.body.appendChild(el); + }, + { once: true } +); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js new file mode 100644 index 0000000000..24a26cb8d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js @@ -0,0 +1,9 @@ +"use strict"; + +var request = new XMLHttpRequest(); +request.open( + "get", + "http://example.com/browser/toolkit/modules/tests/browser/xhr_resource", + false +); +request.send(); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html new file mode 100644 index 0000000000..c4e7db14e7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> +<div id="host">host</div> +<script> + "use strict"; + document.getElementById("host").attachShadow({mode: "closed"}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_good.css b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css new file mode 100644 index 0000000000..46f9774b5f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css @@ -0,0 +1,3 @@ +#test { + color: red; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css new file mode 100644 index 0000000000..6a9140d97e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css @@ -0,0 +1 @@ +:root { color: green; } diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html new file mode 100644 index 0000000000..6d6d187a27 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html @@ -0,0 +1,3 @@ +<!doctype html> +<meta charset=utf-8> +<link rel=stylesheet href=file_stylesheet_cache.css> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html new file mode 100644 index 0000000000..07a4324c44 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html @@ -0,0 +1,19 @@ +<!doctype html> +<meta charset=utf-8> +<!-- The first one should hit the cache, the second one should not. --> +<link rel=stylesheet href=file_stylesheet_cache.css> +<script> + "use strict"; + // This script guarantees that the load of the above stylesheet has happened + // by now. + // + // Now we can go ahead and load the other one programmatically. It's + // important that we don't just throw a <link> in the markup below to + // guarantee + // that the load happens afterwards (that is, to cheat the parser's speculative + // load mechanism). + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "file_stylesheet_cache.css?2"; + document.head.appendChild(link); +</script> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html new file mode 100644 index 0000000000..d93813d0f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Top-level frame document</title> +</head> +<body> + <iframe src="file_iframe.html"></iframe> + <iframe src="about:blank"></iframe> + <iframe srcdoc="Iframe srcdoc"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html new file mode 100644 index 0000000000..199c2ce4d4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Document with example.org frame</title> +</head> +<body> + <iframe src="http://example.org/data/file_iframe.html"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz Binary files differnew file mode 100644 index 0000000000..9eb8d73d50 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif Binary files differnew file mode 100644 index 0000000000..baf8166dae --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif Binary files differnew file mode 100644 index 0000000000..48f97f74bd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif diff --git a/toolkit/components/extensions/test/xpcshell/head.js b/toolkit/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..4608f77bd6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head.js @@ -0,0 +1,277 @@ +"use strict"; + +/* exported createHttpServer, cleanupDir, clearCache, promiseConsoleOutput, + promiseQuotaManagerServiceReset, promiseQuotaManagerServiceClear, + runWithPrefs, testEnv, withHandlingUserInput, resetHandlingUserInput */ + +var { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { + clearInterval, + clearTimeout, + setInterval, + setIntervalWithTarget, + setTimeout, + setTimeoutWithTarget, +} = ChromeUtils.import("resource://gre/modules/Timer.jsm"); +var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.import( + "resource://testing-common/AddonTestUtils.jsm" +); + +// eslint-disable-next-line no-unused-vars +XPCOMUtils.defineLazyModuleGetters(this, { + ContentTask: "resource://testing-common/ContentTask.jsm", + Extension: "resource://gre/modules/Extension.jsm", + ExtensionData: "resource://gre/modules/Extension.jsm", + ExtensionParent: "resource://gre/modules/ExtensionParent.jsm", + ExtensionTestUtils: "resource://testing-common/ExtensionXPCShellUtils.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + MessageChannel: "resource://gre/modules/MessageChannel.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", + PromiseTestUtils: "resource://testing-common/PromiseTestUtils.jsm", + Schemas: "resource://gre/modules/Schemas.jsm", +}); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); + +// These values may be changed in later head files and tested in check_remote +// below. +Services.prefs.setBoolPref("browser.tabs.remote.autostart", false); +Services.prefs.setBoolPref("extensions.webextensions.remote", false); +const testEnv = { + expectRemote: false, +}; + +add_task(function check_remote() { + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + testEnv.expectRemote, + "useRemoteWebExtensions matches" + ); + Assert.equal( + WebExtensionPolicy.isExtensionProcess, + !testEnv.expectRemote, + "testing from extension process" + ); +}); + +ExtensionTestUtils.init(this); + +var createHttpServer = (...args) => { + AddonTestUtils.maybeInit(this); + return AddonTestUtils.createHttpServer(...args); +}; + +if (AppConstants.platform === "android") { + Services.io.offline = true; +} + +/** + * Clears the HTTP and content image caches. + */ +function clearCache() { + Services.cache2.clear(); + + let imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(null); + imageCache.clearCache(false); +} + +var promiseConsoleOutput = async function(task) { + const DONE = `=== console listener ${Math.random()} done ===`; + + let listener; + let messages = []; + let awaitListener = new Promise(resolve => { + listener = msg => { + if (msg == DONE) { + resolve(); + } else { + void (msg instanceof Ci.nsIConsoleMessage); + void (msg instanceof Ci.nsIScriptError); + messages.push(msg); + } + }; + }); + + Services.console.registerListener(listener); + try { + let result = await task(); + + Services.console.logStringMessage(DONE); + await awaitListener; + + return { messages, result }; + } finally { + Services.console.unregisterListener(listener); + } +}; + +// Attempt to remove a directory. If the Windows OS is still using the +// file sometimes remove() will fail. So try repeatedly until we can +// remove it or we give up. +function cleanupDir(dir) { + let count = 0; + return new Promise((resolve, reject) => { + function tryToRemoveDir() { + count += 1; + try { + dir.remove(true); + } catch (e) { + // ignore + } + if (!dir.exists()) { + return resolve(); + } + if (count >= 25) { + return reject(`Failed to cleanup directory: ${dir}`); + } + setTimeout(tryToRemoveDir, 100); + } + tryToRemoveDir(); + }); +} + +// Run a test with the specified preferences and then restores their initial values +// right after the test function run (whether it passes or fails). +async function runWithPrefs(prefsToSet, testFn) { + const setPrefs = prefs => { + for (let [pref, value] of prefs) { + if (value === undefined) { + // Clear any pref that didn't have a user value. + info(`Clearing pref "${pref}"`); + Services.prefs.clearUserPref(pref); + continue; + } + + info(`Setting pref "${pref}": ${value}`); + switch (typeof value) { + case "boolean": + Services.prefs.setBoolPref(pref, value); + break; + case "number": + Services.prefs.setIntPref(pref, value); + break; + case "string": + Services.prefs.setStringPref(pref, value); + break; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + } + }; + + const getPrefs = prefs => { + return prefs.map(([pref, value]) => { + info(`Getting initial pref value for "${pref}"`); + if (!Services.prefs.prefHasUserValue(pref)) { + // Check if the pref doesn't have a user value. + return [pref, undefined]; + } + switch (typeof value) { + case "boolean": + return [pref, Services.prefs.getBoolPref(pref)]; + case "number": + return [pref, Services.prefs.getIntPref(pref)]; + case "string": + return [pref, Services.prefs.getStringPref(pref)]; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + }); + }; + + let initialPrefsValues = []; + + try { + initialPrefsValues = getPrefs(prefsToSet); + + setPrefs(prefsToSet); + + await testFn(); + } finally { + info("Restoring initial preferences values on exit"); + setPrefs(initialPrefsValues); + } +} + +// "Handling User Input" test helpers. + +let extensionHandlers = new WeakSet(); + +function handlingUserInputFrameScript() { + /* globals content */ + // eslint-disable-next-line no-shadow + const { MessageChannel } = ChromeUtils.import( + "resource://gre/modules/MessageChannel.jsm" + ); + + let handle; + MessageChannel.addListener(this, "ExtensionTest:HandleUserInput", { + receiveMessage({ name, data }) { + if (data) { + handle = content.windowUtils.setHandlingUserInput(true); + } else if (handle) { + handle.destruct(); + handle = null; + } + }, + }); +} + +// If you use withHandlingUserInput then restart the addon manager, +// you need to reset this before using withHandlingUserInput again. +function resetHandlingUserInput() { + extensionHandlers = new WeakSet(); +} + +async function withHandlingUserInput(extension, fn) { + let { messageManager } = extension.extension.groupFrameLoader; + + if (!extensionHandlers.has(extension)) { + messageManager.loadFrameScript( + `data:,(${encodeURI(handlingUserInputFrameScript)}).call(this)`, + false, + true + ); + extensionHandlers.add(extension); + } + + await MessageChannel.sendMessage( + messageManager, + "ExtensionTest:HandleUserInput", + true + ); + await fn(); + await MessageChannel.sendMessage( + messageManager, + "ExtensionTest:HandleUserInput", + false + ); +} + +// QuotaManagerService test helpers. + +function promiseQuotaManagerServiceReset() { + info("Calling QuotaManagerService.reset to enforce new test storage limits"); + return new Promise(resolve => { + Services.qms.reset().callback = resolve; + }); +} + +function promiseQuotaManagerServiceClear() { + info( + "Calling QuotaManagerService.clear to empty the test data and refresh test storage limits" + ); + return new Promise(resolve => { + Services.qms.clear().callback = resolve; + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_e10s.js b/toolkit/components/extensions/test/xpcshell/head_e10s.js new file mode 100644 index 0000000000..196afae7c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_e10s.js @@ -0,0 +1,8 @@ +"use strict"; + +/* globals ExtensionTestUtils */ + +// xpcshell disables e10s by default. Turn it on. +Services.prefs.setBoolPref("browser.tabs.remote.autostart", true); + +ExtensionTestUtils.remoteContentScripts = true; diff --git a/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js new file mode 100644 index 0000000000..01f16ec54c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js @@ -0,0 +1,13 @@ +"use strict"; + +// Bug 1646182: Test the legacy ExtensionPermission backend until we fully +// migrate to rkv + +{ + const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" + ); + + ExtensionPermissions._useLegacyStorageBackend = true; + ExtensionPermissions._uninit(); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_native_messaging.js b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js new file mode 100644 index 0000000000..e0b977c22c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js @@ -0,0 +1,153 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals AppConstants, FileUtils */ +/* exported getSubprocessCount, setupHosts, waitForSubprocessExit */ + +ChromeUtils.defineModuleGetter( + this, + "MockRegistry", + "resource://testing-common/MockRegistry.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); + +let { Subprocess, SubprocessImpl } = ChromeUtils.import( + "resource://gre/modules/Subprocess.jsm", + null +); + +// It's important that we use a space in this directory name to make sure we +// correctly handle executing batch files with spaces in their path. +let tmpDir = FileUtils.getDir("TmpD", ["Native Messaging"]); +tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +const TYPE_SLUG = + AppConstants.platform === "linux" + ? "native-messaging-hosts" + : "NativeMessagingHosts"; +OS.File.makeDir(OS.Path.join(tmpDir.path, TYPE_SLUG)); + +registerCleanupFunction(() => { + tmpDir.remove(true); +}); + +function getPath(filename) { + return OS.Path.join(tmpDir.path, TYPE_SLUG, filename); +} + +const ID = "native@tests.mozilla.org"; + +async function setupHosts(scripts) { + const PERMS = { unixMode: 0o755 }; + + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + const pythonPath = await Subprocess.pathSearch(env.get("PYTHON")); + + async function writeManifest(script, scriptPath, path) { + let body = `#!${pythonPath} -u\n${script.script}`; + + await OS.File.writeAtomic(scriptPath, body); + await OS.File.setPermissions(scriptPath, PERMS); + + let manifest = { + name: script.name, + description: script.description, + path, + type: "stdio", + allowed_extensions: [ID], + }; + + let manifestPath = getPath(`${script.name}.json`); + await OS.File.writeAtomic(manifestPath, JSON.stringify(manifest)); + + return manifestPath; + } + + switch (AppConstants.platform) { + case "macosx": + case "linux": + let dirProvider = { + getFile(property) { + if (property == "XREUserNativeManifests") { + return tmpDir.clone(); + } else if (property == "XRESysNativeManifests") { + return tmpDir.clone(); + } + return null; + }, + }; + + Services.dirsvc.registerProvider(dirProvider); + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + }); + + for (let script of scripts) { + let path = getPath(`${script.name}.py`); + + await writeManifest(script, path, path); + } + break; + + case "win": + const REGKEY = String.raw`Software\Mozilla\NativeMessagingHosts`; + + let registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); + + for (let script of scripts) { + let { scriptExtension = "bat" } = script; + + // It's important that we use a space in this filename. See directory + // name comment above. + let batPath = getPath(`batch ${script.name}.${scriptExtension}`); + let scriptPath = getPath(`${script.name}.py`); + + let batBody = `@ECHO OFF\n${pythonPath} -u "${scriptPath}" %*\n`; + await OS.File.writeAtomic(batPath, batBody); + + // Create absolute and relative path versions of the entry. + for (let [name, path] of [ + [script.name, batPath], + [`relative.${script.name}`, OS.Path.basename(batPath)], + ]) { + script.name = name; + let manifestPath = await writeManifest(script, scriptPath, path); + + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGKEY}\\${script.name}`, + "", + manifestPath + ); + } + } + break; + + default: + ok( + false, + `Native messaging is not supported on ${AppConstants.platform}` + ); + } +} + +function getSubprocessCount() { + return SubprocessImpl.Process.getWorker() + .call("getProcesses", []) + .then(result => result.size); +} +function waitForSubprocessExit() { + return SubprocessImpl.Process.getWorker() + .call("waitForNoProcesses", []) + .then(() => { + // Return to the main event loop to give IO handlers enough time to consume + // their remaining buffered input. + return new Promise(resolve => setTimeout(resolve, 0)); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_remote.js b/toolkit/components/extensions/test/xpcshell/head_remote.js new file mode 100644 index 0000000000..f9c31144c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_remote.js @@ -0,0 +1,7 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.webextensions.remote", true); +Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.extension", 1); + +/* globals testEnv */ +testEnv.expectRemote = true; // tested in head_test.js diff --git a/toolkit/components/extensions/test/xpcshell/head_storage.js b/toolkit/components/extensions/test/xpcshell/head_storage.js new file mode 100644 index 0000000000..09a5b45b0e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_storage.js @@ -0,0 +1,1227 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from head.js */ + +const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + +// Test implementations and utility functions that are used against multiple +// storage areas (eg, a test which is run against browser.storage.local and +// browser.storage.sync, or a test against browser.storage.sync but needs to +// be run against both the kinto and rust implementations.) + +/** + * Utility function to ensure that all supported APIs for getting are + * tested. + * + * @param {string} areaName + * either "local" or "sync" according to what we want to test + * @param {string} prop + * "key" to look up using the storage API + * @param {Object} value + * "value" to compare against + */ +async function checkGetImpl(areaName, prop, value) { + let storage = browser.storage[areaName]; + + let data = await storage.get(); + browser.test.assertEq( + value, + data[prop], + `unspecified getter worked for ${prop} in ${areaName}` + ); + + data = await storage.get(null); + browser.test.assertEq( + value, + data[prop], + `null getter worked for ${prop} in ${areaName}` + ); + + data = await storage.get(prop); + browser.test.assertEq( + value, + data[prop], + `string getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `string getter should return an object with a single property` + ); + + data = await storage.get([prop]); + browser.test.assertEq( + value, + data[prop], + `array getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `array getter with a single key should return an object with a single property` + ); + + data = await storage.get({ [prop]: undefined }); + browser.test.assertEq( + value, + data[prop], + `object getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `object getter with a single key should return an object with a single property` + ); +} + +function test_config_flag_needed() { + async function testFn() { + function background() { + let promises = []; + let apiTests = [ + { method: "get", args: ["foo"] }, + { method: "set", args: [{ foo: "bar" }] }, + { method: "remove", args: ["foo"] }, + { method: "clear", args: [] }, + ]; + apiTests.forEach(testDef => { + promises.push( + browser.test.assertRejects( + browser.storage.sync[testDef.method](...testDef.args), + "Please set webextensions.storage.sync.enabled to true in about:config", + `storage.sync.${testDef.method} is behind a flag` + ) + ); + }); + + Promise.all(promises).then(() => browser.test.notifyPass("flag needed")); + } + + ok( + !Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to false" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("flag needed"); + await extension.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, false]], testFn); +} + +function test_sync_reloading_extensions_works() { + async function testFn() { + // Just some random extension ID that we can re-use + const extensionId = "my-extension-id@1"; + + function loadExtension() { + function background() { + browser.storage.sync.set({ a: "b" }).then(() => { + browser.test.notifyPass("set-works"); + }); + } + + return ExtensionTestUtils.loadExtension( + { + manifest: { + permissions: ["storage"], + }, + background: `(${background})()`, + }, + extensionId + ); + } + + ok( + Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to true" + ); + + let extension1 = loadExtension(); + + await extension1.startup(); + await extension1.awaitFinish("set-works"); + await extension1.unload(); + + let extension2 = loadExtension(); + + await extension2.startup(); + await extension2.awaitFinish("set-works"); + await extension2.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, true]], testFn); +} + +async function test_background_page_storage(testAreaName) { + async function backgroundScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { + gResolve = resolve; + }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq( + expectedAreaName, + areaName, + "Expected area name received by listener" + ); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue( + obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertTrue( + obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertEq( + obj1[prop].oldValue, + obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})` + ); + browser.test.assertEq( + obj1[prop].newValue, + obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})` + ); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + // Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1645598 + async function testNonExistingKeys(storage, storageAreaDesc) { + let data = await storage.get({ test6: 6 }); + browser.test.assertEq( + `{"test6":6}`, + JSON.stringify(data), + `Use default value when not stored for ${storageAreaDesc}` + ); + + data = await storage.get({ test6: null }); + browser.test.assertEq( + `{"test6":null}`, + JSON.stringify(data), + `Use default value, even if null for ${storageAreaDesc}` + ); + + data = await storage.get("test6"); + browser.test.assertEq( + `{}`, + JSON.stringify(data), + `Empty result if key is not found for ${storageAreaDesc}` + ); + + data = await storage.get(["test6", "test7"]); + browser.test.assertEq( + `{}`, + JSON.stringify(data), + `Empty result if list of keys is not found for ${storageAreaDesc}` + ); + } + + async function testFalseyValues(areaName) { + let storage = browser.storage[areaName]; + const dataInitial = { + "test-falsey-value-bool": false, + "test-falsey-value-string": "", + "test-falsey-value-number": 0, + }; + const dataUpdate = { + "test-falsey-value-bool": true, + "test-falsey-value-string": "non-empty-string", + "test-falsey-value-number": 10, + }; + + // Compute the expected changes. + const onSetInitial = { + "test-falsey-value-bool": { newValue: false }, + "test-falsey-value-string": { newValue: "" }, + "test-falsey-value-number": { newValue: 0 }, + }; + const onRemovedFalsey = { + "test-falsey-value-bool": { oldValue: false }, + "test-falsey-value-string": { oldValue: "" }, + "test-falsey-value-number": { oldValue: 0 }, + }; + const onUpdatedFalsey = { + "test-falsey-value-bool": { newValue: true, oldValue: false }, + "test-falsey-value-string": { + newValue: "non-empty-string", + oldValue: "", + }, + "test-falsey-value-number": { newValue: 10, oldValue: 0 }, + }; + const keys = Object.keys(dataInitial); + + // Test on removing falsey values. + await storage.set(dataInitial); + await checkChanges(areaName, onSetInitial, "set falsey values"); + await storage.remove(keys); + await checkChanges(areaName, onRemovedFalsey, "remove falsey value"); + + // Test on updating falsey values. + await storage.set(dataInitial); + await checkChanges(areaName, onSetInitial, "set falsey values"); + await storage.set(dataUpdate); + await checkChanges(areaName, onUpdatedFalsey, "set non-falsey values"); + + // Clear the storage state. + await testNonExistingKeys(storage, `${areaName} before clearing`); + await storage.clear(); + await testNonExistingKeys(storage, `${areaName} after clearing`); + await globalChanges; + clearGlobalChanges(); + } + + function CustomObj() { + this.testKey1 = "testValue1"; + } + + CustomObj.prototype.toString = function() { + return '{"testKey2":"testValue2"}'; + }; + + CustomObj.prototype.toJSON = function customObjToJSON() { + return { testKey1: "testValue3" }; + }; + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + await checkChanges( + areaName, + { + "test-prop1": { newValue: "value1" }, + "test-prop2": { newValue: "value2" }, + }, + "set (a)" + ); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + other: "default", + }); + browser.test.assertEq( + "value1", + data["test-prop1"], + "prop1 correct (a)" + ); + browser.test.assertEq( + "value2", + data["test-prop2"], + "prop2 correct (a)" + ); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq( + "value1", + data["test-prop1"], + "prop1 correct (b)" + ); + browser.test.assertEq( + "value2", + data["test-prop2"], + "prop2 correct (b)" + ); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges( + areaName, + { "test-prop1": { oldValue: "value1" } }, + "remove string" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove string)" + ); + browser.test.assertTrue( + "test-prop2" in data, + "prop2 present (remove string)" + ); + + await storage.set({ "test-prop1": "value1" }); + await checkChanges( + areaName, + { "test-prop1": { newValue: "value1" } }, + "set (c)" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq( + data["test-prop1"], + "value1", + "prop1 correct (c)" + ); + browser.test.assertEq( + data["test-prop2"], + "value2", + "prop2 correct (c)" + ); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "remove array" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove array)" + ); + browser.test.assertFalse( + "test-prop2" in data, + "prop2 absent (remove array)" + ); + + await testFalseyValues(areaName); + + // test storage.clear + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "clear" + ); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + + // Make sure the set() handler landed. + await globalChanges; + + let date = new Date(0); + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + nestedObj: { + testKey: {}, + }, + intKeyObj: { + 4: "testValue1", + 3: "testValue2", + 99: "testValue3", + }, + floatKeyObj: { + 1.4: "testValue1", + 5.5: "testValue2", + }, + customObj: new CustomObj(), + arr: [1, 2], + nestedArr: [1, [2, 3]], + date, + regexp: /regexp/, + }, + }); + + await browser.test.assertRejects( + storage.set({ + window, + }), + /DataCloneError|cyclic object value/ + ); + + await browser.test.assertRejects( + storage.set({ "test-prop2": function func() {} }), + /DataCloneError/ + ); + + const recentChanges = await globalChanges; + + browser.test.assertEq( + "value1", + recentChanges["test-prop1"].oldValue, + "oldValue correct" + ); + browser.test.assertEq( + "object", + typeof recentChanges["test-prop1"].newValue, + "newValue is obj" + ); + clearGlobalChanges(); + + data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + }); + let obj = data["test-prop1"]; + + browser.test.assertEq( + "object", + typeof obj.customObj, + "custom object part correct" + ); + browser.test.assertEq( + 1, + Object.keys(obj.customObj).length, + "customObj keys correct" + ); + + if (areaName === "local") { + browser.test.assertEq( + String(date), + String(obj.date), + "date part correct" + ); + browser.test.assertEq( + "/regexp/", + obj.regexp.toString(), + "regexp part correct" + ); + // storage.local doesn't call toJSON + browser.test.assertEq( + "testValue1", + obj.customObj.testKey1, + "customObj keys correct" + ); + } else { + browser.test.assertEq( + "1970-01-01T00:00:00.000Z", + String(obj.date), + "date part correct" + ); + + browser.test.assertEq( + "object", + typeof obj.regexp, + "regexp part is an object" + ); + browser.test.assertEq( + 0, + Object.keys(obj.regexp).length, + "regexp part is an empty object" + ); + // storage.sync does call toJSON + browser.test.assertEq( + "testValue3", + obj.customObj.testKey1, + "customObj keys correct" + ); + } + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("object", typeof obj.obj, "object part correct"); + browser.test.assertEq( + "object", + typeof obj.nestedObj, + "nested object part correct" + ); + browser.test.assertEq( + "object", + typeof obj.nestedObj.testKey, + "nestedObj.testKey part correct" + ); + browser.test.assertEq( + "object", + typeof obj.intKeyObj, + "int key object part correct" + ); + browser.test.assertEq( + "testValue1", + obj.intKeyObj[4], + "intKeyObj[4] part correct" + ); + browser.test.assertEq( + "testValue2", + obj.intKeyObj[3], + "intKeyObj[3] part correct" + ); + browser.test.assertEq( + "testValue3", + obj.intKeyObj[99], + "intKeyObj[99] part correct" + ); + browser.test.assertEq( + "object", + typeof obj.floatKeyObj, + "float key object part correct" + ); + browser.test.assertEq( + "testValue1", + obj.floatKeyObj[1.4], + "floatKeyObj[1.4] part correct" + ); + browser.test.assertEq( + "testValue2", + obj.floatKeyObj[5.5], + "floatKeyObj[5.5] part correct" + ); + + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + browser.test.assertTrue( + Array.isArray(obj.nestedArr), + "nested array part present" + ); + browser.test.assertEq( + 2, + obj.nestedArr.length, + "nestedArr.length part correct" + ); + browser.test.assertEq(1, obj.nestedArr[0], "nestedArr[0] part correct"); + browser.test.assertTrue( + Array.isArray(obj.nestedArr[1]), + "nestedArr[1] part present" + ); + browser.test.assertEq( + 2, + obj.nestedArr[1].length, + "nestedArr[1].length part correct" + ); + browser.test.assertEq( + 2, + obj.nestedArr[1][0], + "nestedArr[1][0] part correct" + ); + browser.test.assertEq( + 3, + obj.nestedArr[1][1], + "nestedArr[1][1] part correct" + ); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); + } + + let extensionData = { + background: `(${backgroundScript})(${checkGetImpl})`, + manifest: { + permissions: ["storage"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${testAreaName}`); + await extension.awaitMessage("test-finished"); + + await extension.unload(); +} + +function test_storage_sync_requires_real_id() { + async function testFn() { + async function background() { + const EXCEPTION_MESSAGE = + "The storage API is not available with a temporary addon ID. " + + "Please add an explicit addon ID to your manifest. " + + "For more information see https://mzl.la/3lPk1aE."; + + await browser.test.assertRejects( + browser.storage.sync.set({ foo: "bar" }), + EXCEPTION_MESSAGE + ); + + browser.test.notifyPass("exception correct"); + } + + let extensionData = { + background, + manifest: { + permissions: ["storage"], + }, + useAddonManager: "temporary", + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("exception correct"); + + await extension.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, true]], testFn); +} + +// Test for storage areas which don't support getBytesInUse() nor QUOTA +// constants. +async function check_storage_area_no_bytes_in_use(area) { + let impl = browser.storage[area]; + + browser.test.assertEq( + typeof impl.getBytesInUse, + "undefined", + "getBytesInUse API method should not be available" + ); + browser.test.sendMessage("test-complete"); +} + +async function test_background_storage_area_no_bytes_in_use(area) { + const EXT_ID = "test-gbiu@mozilla.org"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXT_ID } }, + }, + background: `(${check_storage_area_no_bytes_in_use})("${area}")`, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await extension.awaitMessage("test-complete"); + await extension.unload(); +} + +async function test_contentscript_storage_area_no_bytes_in_use(area) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + function contentScript(checkImpl) { + browser.test.onMessage.addListener(msg => { + if (msg === "test-local") { + checkImpl("local"); + } else if (msg === "test-sync") { + checkImpl("sync"); + } else { + browser.test.fail(`Unexpected test message received: ${msg}`); + browser.test.sendMessage("test-complete"); + } + }); + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${contentScript})(${check_storage_area_no_bytes_in_use})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${area}`); + await extension.awaitMessage("test-complete"); + + await extension.unload(); + await contentPage.close(); +} + +// Test for storage areas which do support getBytesInUse() (but which may or may +// not support enforcement of the quota) +async function check_storage_area_with_bytes_in_use(area, expectQuota) { + let impl = browser.storage[area]; + + // QUOTA_* constants aren't currently exposed - see bug 1396810. + // However, the quotas are still enforced, so test them here. + // (Note that an implication of this is that we can't test area other than + // 'sync', because its limits are different - so for completeness...) + browser.test.assertEq( + area, + "sync", + "Running test on storage.sync API as expected" + ); + const QUOTA_BYTES_PER_ITEM = 8192; + const MAX_ITEMS = 512; + + // bytes is counted as "length of key as a string, length of value as + // JSON" - ie, quotes not counted in the key, but are in the value. + let value = "x".repeat(QUOTA_BYTES_PER_ITEM - 3); + + await impl.set({ x: value }); // Shouldn't reject on either kinto or rust-based storage.sync. + browser.test.assertEq(await impl.getBytesInUse(null), QUOTA_BYTES_PER_ITEM); + // kinto does implement getBytesInUse() but doesn't enforce a quota. + if (expectQuota) { + await browser.test.assertRejects( + impl.set({ x: value + "x" }), + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + // MAX_ITEMS + await impl.clear(); + let ob = {}; + for (let i = 0; i < MAX_ITEMS; i++) { + ob[`key-${i}`] = "x"; + } + await impl.set(ob); // should work. + await browser.test.assertRejects( + impl.set({ straw: "camel's back" }), // exceeds MAX_ITEMS + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + // QUOTA_BYTES is being already tested for the underlying StorageSyncService + // so we don't duplicate those tests here. + } else { + // Exceeding quota should work on the previous kinto-based storage.sync implementation + await impl.set({ x: value + "x" }); // exceeds quota but should work. + browser.test.assertEq( + await impl.getBytesInUse(null), + QUOTA_BYTES_PER_ITEM + 1, + "Got the expected result from getBytesInUse" + ); + } + browser.test.sendMessage("test-complete"); +} + +async function test_background_storage_area_with_bytes_in_use( + area, + expectQuota +) { + const EXT_ID = "test-gbiu@mozilla.org"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXT_ID } }, + }, + background: `(${check_storage_area_with_bytes_in_use})("${area}", ${expectQuota})`, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await extension.awaitMessage("test-complete"); + await extension.unload(); +} + +async function test_contentscript_storage_area_with_bytes_in_use( + area, + expectQuota +) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + function contentScript(checkImpl) { + browser.test.onMessage.addListener(([area, expectQuota]) => { + if ( + !["local", "sync"].includes(area) || + typeof expectQuota !== "boolean" + ) { + browser.test.fail(`Unexpected test message: [${area}, ${expectQuota}]`); + // Let the test to fail immediately instead of wait for a timeout failure. + browser.test.sendMessage("test-complete"); + return; + } + checkImpl(area, expectQuota); + }); + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${contentScript})(${check_storage_area_with_bytes_in_use})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage([area, expectQuota]); + await extension.awaitMessage("test-complete"); + + await extension.unload(); + await contentPage.close(); +} + +// A couple of common tests for checking content scripts. +async function testStorageContentScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { + gResolve = resolve; + }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq( + expectedAreaName, + areaName, + "Expected area name received by listener" + ); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue( + obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertTrue( + obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertEq( + obj1[prop].oldValue, + obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})` + ); + browser.test.assertEq( + obj1[prop].newValue, + obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})` + ); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + await checkChanges( + areaName, + { + "test-prop1": { newValue: "value1" }, + "test-prop2": { newValue: "value2" }, + }, + "set (a)" + ); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + other: "default", + }); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)"); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)"); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges( + areaName, + { "test-prop1": { oldValue: "value1" } }, + "remove string" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove string)" + ); + browser.test.assertTrue( + "test-prop2" in data, + "prop2 present (remove string)" + ); + + await storage.set({ "test-prop1": "value1" }); + await checkChanges( + areaName, + { "test-prop1": { newValue: "value1" } }, + "set (c)" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)"); + browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)"); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "remove array" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove array)" + ); + browser.test.assertFalse( + "test-prop2" in data, + "prop2 absent (remove array)" + ); + + // test storage.clear + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "clear" + ); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + + // Make sure the set() handler landed. + await globalChanges; + + let date = new Date(0); + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + arr: [1, 2], + date: new Date(0), + regexp: /regexp/, + }, + }); + + await browser.test.assertRejects( + storage.set({ + window, + }), + /DataCloneError|cyclic object value/ + ); + + await browser.test.assertRejects( + storage.set({ "test-prop2": function func() {} }), + /DataCloneError/ + ); + + const recentChanges = await globalChanges; + + browser.test.assertEq( + "value1", + recentChanges["test-prop1"].oldValue, + "oldValue correct" + ); + browser.test.assertEq( + "object", + typeof recentChanges["test-prop1"].newValue, + "newValue is obj" + ); + clearGlobalChanges(); + + data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + }); + let obj = data["test-prop1"]; + + if (areaName === "local") { + browser.test.assertEq( + String(date), + String(obj.date), + "date part correct" + ); + browser.test.assertEq( + "/regexp/", + obj.regexp.toString(), + "regexp part correct" + ); + } else { + browser.test.assertEq( + "1970-01-01T00:00:00.000Z", + String(obj.date), + "date part correct" + ); + + browser.test.assertEq( + "object", + typeof obj.regexp, + "regexp part is an object" + ); + browser.test.assertEq( + 0, + Object.keys(obj.regexp).length, + "regexp part is an empty object" + ); + } + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("object", typeof obj.obj, "object part correct"); + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); +} + +async function test_contentscript_storage(storageType) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${testStorageContentScript})(${checkGetImpl})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${storageType}`); + await extension.awaitMessage("test-finished"); + + await extension.unload(); + await contentPage.close(); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_sync.js b/toolkit/components/extensions/test/xpcshell/head_sync.js new file mode 100644 index 0000000000..691743c696 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_sync.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* exported withSyncContext */ + +ChromeUtils.import("resource://gre/modules/Services.jsm", this); +ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm", this); + +class KintoExtContext extends ExtensionCommon.BaseContext { + constructor(principal) { + super(); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, { wantXrays: false }); + this.extension = { id: "test@web.extension" }; + } + + get cloneScope() { + return this.sandbox; + } +} + +/** + * Call the given function with a newly-constructed context. + * Unload the context on the way out. + * + * @param {function} f the function to call + */ +async function withContext(f) { + const ssm = Services.scriptSecurityManager; + const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin( + "http://www.example.org" + ); + const context = new KintoExtContext(PRINCIPAL1); + try { + await f(context); + } finally { + await context.unload(); + } +} + +/** + * Like withContext(), but also turn on the "storage.sync" pref for + * the duration of the function. + * Calls to this function can be replaced with calls to withContext + * once the pref becomes on by default. + * + * @param {function} f the function to call + */ +async function withSyncContext(f) { + const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + let prefs = Services.prefs; + + try { + prefs.setBoolPref(STORAGE_SYNC_PREF, true); + await withContext(f); + } finally { + prefs.clearUserPref(STORAGE_SYNC_PREF); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/head_telemetry.js b/toolkit/components/extensions/test/xpcshell/head_telemetry.js new file mode 100644 index 0000000000..6492d8f995 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_telemetry.js @@ -0,0 +1,110 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported IS_OOP, valueSum, clearHistograms, getSnapshots, promiseTelemetryRecorded */ + +ChromeUtils.defineModuleGetter( + this, + "ContentTaskUtils", + "resource://testing-common/ContentTaskUtils.jsm" +); + +const IS_OOP = Services.prefs.getBoolPref("extensions.webextensions.remote"); + +function valueSum(arr) { + return Object.values(arr).reduce((a, b) => a + b, 0); +} + +function clearHistograms() { + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); + Services.telemetry.getSnapshotForKeyedHistograms("main", true /* clear */); +} + +function getSnapshots(process) { + return Services.telemetry.getSnapshotForHistograms("main", false /* clear */)[ + process + ]; +} + +function getKeyedSnapshots(process) { + return Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false /* clear */ + )[process]; +} + +// TODO Bug 1357509: There is no good way to make sure that the parent received +// the histogram entries from the extension and content processes. Let's stick +// to the ugly, spinning the event loop until we have a good approach. +function promiseTelemetryRecorded(id, process, expectedCount) { + let condition = () => { + let snapshot = Services.telemetry.getSnapshotForHistograms( + "main", + false /* clear */ + )[process][id]; + return snapshot && valueSum(snapshot.values) >= expectedCount; + }; + return ContentTaskUtils.waitForCondition(condition); +} + +function promiseKeyedTelemetryRecorded( + id, + process, + expectedKey, + expectedCount +) { + let condition = () => { + let snapshot = Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false /* clear */ + )[process][id]; + return ( + snapshot && + snapshot[expectedKey] && + valueSum(snapshot[expectedKey].values) >= expectedCount + ); + }; + return ContentTaskUtils.waitForCondition(condition); +} + +function assertHistogramSnapshot( + histogramId, + { keyed, processSnapshot, expectedValue }, + msg +) { + let histogram; + + if (keyed) { + histogram = Services.telemetry.getKeyedHistogramById(histogramId); + } else { + histogram = Services.telemetry.getHistogramById(histogramId); + } + + let res = processSnapshot(histogram.snapshot()); + Assert.deepEqual(res, expectedValue, msg); + return res; +} + +function assertHistogramEmpty(histogramId) { + assertHistogramSnapshot( + histogramId, + { + processSnapshot: snapshot => snapshot.sum, + expectedValue: 0, + }, + `No data recorded for histogram: ${histogramId}.` + ); +} + +function assertKeyedHistogramEmpty(histogramId) { + assertHistogramSnapshot( + histogramId, + { + keyed: true, + processSnapshot: snapshot => Object.keys(snapshot).length, + expectedValue: 0, + }, + `No data recorded for histogram: ${histogramId}.` + ); +} diff --git a/toolkit/components/extensions/test/xpcshell/native_messaging.ini b/toolkit/components/extensions/test/xpcshell/native_messaging.ini new file mode 100644 index 0000000000..b64cda83c8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/native_messaging.ini @@ -0,0 +1,15 @@ +[DEFAULT] +head = head.js head_e10s.js head_native_messaging.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +subprocess = true +support-files = + data/** +tags = webextensions + +[test_ext_native_messaging.js] +skip-if = (os == "win" && processor == "aarch64") # bug 1530841 +[test_ext_native_messaging_perf.js] +skip-if = tsan # Unreasonably slow, bug 1612707 +[test_ext_native_messaging_unresponsive.js] diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js new file mode 100644 index 0000000000..ad763cb321 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Import the rust-based and kinto-based implementations +const { extensionStorageSync: rustImpl } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSync.jsm" +); +const { extensionStorageSync: kintoImpl } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSyncKinto.jsm" +); + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +add_task(async function test_sync_migration() { + // There's no good reason to perform this test via test extensions - we just + // call the underlying APIs directly. + + // Set some stuff using the kinto-based impl. + let e1 = { id: "test@mozilla.com" }; + let c1 = { extension: e1, callOnClose() {} }; + await kintoImpl.set(e1, { foo: "bar" }, c1); + + let e2 = { id: "test-2@mozilla.com" }; + let c2 = { extension: e2, callOnClose() {} }; + await kintoImpl.set(e2, { second: "2nd" }, c2); + + let e3 = { id: "test-3@mozilla.com" }; + let c3 = { extension: e3, callOnClose() {} }; + + // And all the data should be magically migrated. + Assert.deepEqual(await rustImpl.get(e1, "foo", c1), { foo: "bar" }); + Assert.deepEqual(await rustImpl.get(e2, null, c2), { second: "2nd" }); + + // Sanity check we really are doing what we think we are - set a value in our + // new one, it should not be reflected by kinto. + await rustImpl.set(e3, { third: "3rd" }, c3); + Assert.deepEqual(await rustImpl.get(e3, null, c3), { third: "3rd" }); + Assert.deepEqual(await kintoImpl.get(e3, null, c3), {}); + // cleanup. + await kintoImpl.clear(e1, c1); + await kintoImpl.clear(e2, c2); + await kintoImpl.clear(e3, c3); + await rustImpl.clear(e1, c1); + await rustImpl.clear(e2, c2); + await rustImpl.clear(e3, c3); +}); + +// It would be great to have failure tests, but that seems impossible to have +// in automated tests given the conditions under which we migrate - it would +// basically require us to arrange for zero free disk space or to somehow +// arrange for sqlite to see an io error. Specially crafted "corrupt" +// sqlite files doesn't help because that file must not exist for us to even +// attempt migration. +// +// But - what we can test is that if .migratedOk on the new impl ever goes to +// false we delegate correctly. +add_task(async function test_sync_migration_delgates() { + let e1 = { id: "test@mozilla.com" }; + let c1 = { extension: e1, callOnClose() {} }; + await kintoImpl.set(e1, { foo: "bar" }, c1); + + // We think migration went OK - `get` shouldn't see kinto. + Assert.deepEqual(rustImpl.get(e1, null, c1), {}); + + info( + "Setting migration failure flag to ensure we delegate to kinto implementation" + ); + rustImpl.migrationOk = false; + // get should now be seeing kinto. + Assert.deepEqual(await rustImpl.get(e1, null, c1), { foo: "bar" }); + // check everything else delegates. + + await rustImpl.set(e1, { foo: "foo" }, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" }); + + Assert.equal(await rustImpl.getBytesInUse(e1, null, c1), 8); + + await rustImpl.remove(e1, "foo", c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), {}); + + await rustImpl.set(e1, { foo: "foo" }, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" }); + await rustImpl.clear(e1, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), {}); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js new file mode 100644 index 0000000000..c0aa4254ad --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js @@ -0,0 +1,552 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_MatchPattern_matches() { + function test(url, pattern, normalized = pattern, options = {}, explicit) { + let uri = Services.io.newURI(url); + + pattern = Array.prototype.concat.call(pattern); + normalized = Array.prototype.concat.call(normalized); + + let patterns = pattern.map(pat => new MatchPattern(pat, options)); + + let set = new MatchPatternSet(pattern, options); + let set2 = new MatchPatternSet(patterns, options); + + deepEqual( + set2.patterns, + patterns, + "Patterns in set should equal the input patterns" + ); + + equal( + set.matches(uri, explicit), + set2.matches(uri, explicit), + "Single pattern and pattern set should return the same match" + ); + + for (let [i, pat] of patterns.entries()) { + equal( + pat.pattern, + normalized[i], + "Pattern property should contain correct normalized pattern value" + ); + } + + if (patterns.length == 1) { + equal( + patterns[0].matches(uri, explicit), + set.matches(uri, explicit), + "Single pattern and string set should return the same match" + ); + } + + return set.matches(uri, explicit); + } + + function pass({ url, pattern, normalized, options, explicit }) { + ok( + test(url, pattern, normalized, options, explicit), + `Expected match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function fail({ url, pattern, normalized, options, explicit }) { + ok( + !test(url, pattern, normalized, options, explicit), + `Expected no match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function invalid({ pattern }) { + Assert.throws( + () => new MatchPattern(pattern), + /.*/, + `Invalid pattern '${pattern}' should throw` + ); + Assert.throws( + () => new MatchPatternSet([pattern]), + /.*/, + `Invalid pattern '${pattern}' should throw` + ); + } + + // Invalid pattern. + invalid({ pattern: "" }); + + // Pattern must include trailing slash. + invalid({ pattern: "http://mozilla.org" }); + + // Protocol not allowed. + invalid({ pattern: "gopher://wuarchive.wustl.edu/" }); + + pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/" }); + pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/" }); + + pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/" }); + pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/" }); + fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/" }); + fail({ url: "ftp://mozilla.org/", pattern: "*://mozilla.org/" }); + + fail({ url: "http://mozilla.com", pattern: "http://*mozilla.com*/" }); + fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/" }); + invalid({ pattern: "http:/mozilla.com/" }); + + pass({ url: "http://google.com", pattern: "http://*.google.com/" }); + pass({ url: "http://docs.google.com", pattern: "http://*.google.com/" }); + + pass({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org/" }); + pass({ url: "http://mozilla.org:8080", pattern: "*://mozilla.org/" }); + fail({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org:8080/" }); + + // Now try with * in the path. + pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/*" }); + pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/*" }); + + pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/*" }); + pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/*" }); + fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/*" }); + fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/*" }); + + pass({ url: "http://google.com", pattern: "http://*.google.com/*" }); + pass({ url: "http://docs.google.com", pattern: "http://*.google.com/*" }); + + // Check path stuff. + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/" }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*" }); + pass({ + url: "http://mozilla.com/abc/def", + pattern: "http://mozilla.com/a*f", + }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*" }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*f" }); + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*e" }); + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*c" }); + + invalid({ pattern: "http:///a.html" }); + pass({ url: "file:///foo", pattern: "file:///foo*" }); + pass({ url: "file:///foo/bar.html", pattern: "file:///foo*" }); + + pass({ url: "http://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "https://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "ftp://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "file:///a", pattern: "<all_urls>" }); + fail({ url: "gopher://wuarchive.wustl.edu/a", pattern: "<all_urls>" }); + + // Multiple patterns. + pass({ url: "http://mozilla.org", pattern: ["http://mozilla.org/"] }); + pass({ + url: "http://mozilla.org", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + pass({ + url: "http://mozilla.com", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + fail({ + url: "http://mozilla.biz", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + + // Match url with fragments. + pass({ + url: "http://mozilla.org/base#some-fragment", + pattern: "http://mozilla.org/base", + }); + + // Match data:-URLs. + pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,foo"] }); + pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,*"] }); + pass({ + url: "data:text/plain;charset=utf-8,foo", + pattern: ["data:text/plain;charset=utf-8,foo"], + }); + fail({ + url: "data:text/plain,foo", + pattern: ["data:text/plain;charset=utf-8,foo"], + }); + fail({ + url: "data:text/plain;charset=utf-8,foo", + pattern: ["data:text/plain,foo"], + }); + + // Privileged matchers: + invalid({ pattern: "about:foo" }); + invalid({ pattern: "resource://foo/*" }); + + pass({ + url: "about:foo", + pattern: ["about:foo", "about:foo*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "about:foo", + pattern: ["about:foo*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "about:foobar", + pattern: ["about:foo*"], + options: { restrictSchemes: false }, + }); + + pass({ + url: "resource://foo/bar", + pattern: ["resource://foo/bar"], + options: { restrictSchemes: false }, + }); + fail({ + url: "resource://fog/bar", + pattern: ["resource://foo/bar"], + options: { restrictSchemes: false }, + }); + fail({ + url: "about:foo", + pattern: ["about:meh"], + options: { restrictSchemes: false }, + }); + + // Matchers for schemes without host should ignore ignorePath. + pass({ + url: "about:reader?http://e.com/", + pattern: ["about:reader*"], + options: { ignorePath: true, restrictSchemes: false }, + }); + pass({ url: "data:,", pattern: ["data:,*"], options: { ignorePath: true } }); + + // Matchers for schems without host should still match even if the explicit (host) flag is set. + pass({ + url: "about:reader?explicit", + pattern: ["about:reader*"], + options: { restrictSchemes: false }, + explicit: true, + }); + pass({ + url: "about:reader?explicit", + pattern: ["about:reader?explicit"], + options: { restrictSchemes: false }, + explicit: true, + }); + pass({ url: "data:,explicit", pattern: ["data:,explicit"], explicit: true }); + pass({ url: "data:,explicit", pattern: ["data:,*"], explicit: true }); + + // Matchers without "//" separator in the pattern. + pass({ url: "data:text/plain;charset=utf-8,foo", pattern: ["data:*"] }); + pass({ + url: "about:blank", + pattern: ["about:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "view-source:https://example.com", + pattern: ["view-source:*"], + options: { restrictSchemes: false }, + }); + invalid({ pattern: ["chrome:*"], options: { restrictSchemes: false } }); + invalid({ pattern: "http:*" }); + + // Matchers for unrecognized schemes. + invalid({ pattern: "unknown-scheme:*" }); + pass({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:foo"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme://foo"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme://*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme:*"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme:foo"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme://foo"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme://*"], + options: { restrictSchemes: false }, + }); + + // Matchers for IPv6 + pass({ url: "http://[::1]/", pattern: ["http://[::1]/"] }); + pass({ + url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/", + pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"], + }); + fail({ + url: "http://[2:4:6:3:2:3:f:b]/", + pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"], + }); + + // Before fixing Bug 1529230, the only way to match a specific IPv6 url is by droping the brackets in pattern, + // thus we keep this pattern valid for the sake of backward compatibility + pass({ url: "http://[::1]/", pattern: ["http://::1/"] }); + pass({ + url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/", + pattern: ["http://2a03:4000:6:310e:216:3eff:fe53:99b/"], + }); +}); + +add_task(async function test_MatchPattern_overlaps() { + function test(filter, hosts, optional) { + filter = Array.prototype.concat.call(filter); + hosts = Array.prototype.concat.call(hosts); + optional = Array.prototype.concat.call(optional); + + const set = new MatchPatternSet([...hosts, ...optional]); + const pat = new MatchPatternSet(filter); + return set.overlapsAll(pat); + } + + function pass({ filter = [], hosts = [], optional = [] }) { + ok( + test(filter, hosts, optional), + `Expected overlap: ${filter}, ${hosts} (${optional})` + ); + } + + function fail({ filter = [], hosts = [], optional = [] }) { + ok( + !test(filter, hosts, optional), + `Expected no overlap: ${filter}, ${hosts} (${optional})` + ); + } + + // Direct comparison. + pass({ hosts: "http://ab.cd/", filter: "http://ab.cd/" }); + fail({ hosts: "http://ab.cd/", filter: "ftp://ab.cd/" }); + + // Wildcard protocol. + pass({ hosts: "*://ab.cd/", filter: "https://ab.cd/" }); + fail({ hosts: "*://ab.cd/", filter: "ftp://ab.cd/" }); + + // Wildcard subdomain. + pass({ hosts: "http://*.ab.cd/", filter: "http://ab.cd/" }); + pass({ hosts: "http://*.ab.cd/", filter: "http://www.ab.cd/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://ab.cd.ef/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://www.cd/" }); + + // Wildcard subsumed. + pass({ hosts: "http://*.ab.cd/", filter: "http://*.cd/" }); + fail({ hosts: "http://*.cd/", filter: "http://*.xy/" }); + + // Subdomain vs substring. + fail({ hosts: "http://*.ab.cd/", filter: "http://fake-ab.cd/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://*.fake-ab.cd/" }); + + // Wildcard domain. + pass({ hosts: "http://*/", filter: "http://ab.cd/" }); + fail({ hosts: "http://*/", filter: "https://ab.cd/" }); + + // Wildcard wildcards. + pass({ hosts: "<all_urls>", filter: "ftp://ab.cd/" }); + fail({ hosts: "<all_urls>" }); + + // Multiple hosts. + pass({ hosts: ["http://ab.cd/"], filter: ["http://ab.cd/"] }); + pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.cd/" }); + pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.xy/" }); + fail({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.zz/" }); + + // Multiple Multiples. + pass({ + hosts: ["http://*.ab.cd/"], + filter: ["http://ab.cd/", "http://www.ab.cd/"], + }); + pass({ + hosts: ["http://ab.cd/", "http://ab.xy/"], + filter: ["http://ab.cd/", "http://ab.xy/"], + }); + fail({ + hosts: ["http://ab.cd/", "http://ab.xy/"], + filter: ["http://ab.cd/", "http://ab.zz/"], + }); + + // Optional. + pass({ hosts: [], optional: "http://ab.cd/", filter: "http://ab.cd/" }); + pass({ + hosts: "http://ab.cd/", + optional: "http://ab.xy/", + filter: ["http://ab.cd/", "http://ab.xy/"], + }); + fail({ + hosts: "http://ab.cd/", + optional: "https://ab.xy/", + filter: "http://ab.xy/", + }); +}); + +add_task(async function test_MatchGlob() { + function test(url, pattern) { + let m = new MatchGlob(pattern[0]); + return m.matches(Services.io.newURI(url).spec); + } + + function pass({ url, pattern }) { + ok( + test(url, pattern), + `Expected match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function fail({ url, pattern }) { + ok( + !test(url, pattern), + `Expected no match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + let moz = "http://mozilla.org"; + + pass({ url: moz, pattern: ["*"] }); + pass({ url: moz, pattern: ["http://*"] }); + pass({ url: moz, pattern: ["*mozilla*"] }); + // pass({url: moz, pattern: ["*example*", "*mozilla*"]}); + + pass({ url: moz, pattern: ["*://*"] }); + pass({ url: "https://mozilla.org", pattern: ["*://*"] }); + + // Documentation example + pass({ + url: "http://www.example.com/foo/bar", + pattern: ["http://???.example.com/foo/*"], + }); + pass({ + url: "http://the.example.com/foo/", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://my.example.com/foo/bar", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://example.com/foo/", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://www.example.com/foo", + pattern: ["http://???.example.com/foo/*"], + }); + + // Matches path + let path = moz + "/abc/def"; + pass({ url: path, pattern: ["*def"] }); + pass({ url: path, pattern: ["*c/d*"] }); + pass({ url: path, pattern: ["*org/abc*"] }); + fail({ url: path + "/", pattern: ["*def"] }); + + // Trailing slash + pass({ url: moz, pattern: ["*.org/"] }); + fail({ url: moz, pattern: ["*.org"] }); + + // Wrong TLD + fail({ url: moz, pattern: ["*oz*.com/"] }); + // Case sensitive + fail({ url: moz, pattern: ["*.ORG/"] }); +}); + +add_task(async function test_MatchPattern_subsumes() { + function test(oldPat, newPat) { + let m = new MatchPatternSet(oldPat); + return m.subsumes(new MatchPattern(newPat)); + } + + function pass({ oldPat, newPat }) { + ok(test(oldPat, newPat), `${JSON.stringify(oldPat)} subsumes "${newPat}"`); + } + + function fail({ oldPat, newPat }) { + ok( + !test(oldPat, newPat), + `${JSON.stringify(oldPat)} doesn't subsume "${newPat}"` + ); + } + + pass({ oldPat: ["<all_urls>"], newPat: "*://*/*" }); + pass({ oldPat: ["<all_urls>"], newPat: "http://*/*" }); + pass({ oldPat: ["<all_urls>"], newPat: "http://*.example.com/*" }); + + pass({ oldPat: ["*://*/*"], newPat: "http://*/*" }); + pass({ oldPat: ["*://*/*"], newPat: "wss://*/*" }); + pass({ oldPat: ["*://*/*"], newPat: "http://*.example.com/*" }); + + pass({ oldPat: ["*://*.example.com/*"], newPat: "http://*.example.com/*" }); + pass({ oldPat: ["*://*.example.com/*"], newPat: "*://sub.example.com/*" }); + + pass({ oldPat: ["https://*/*"], newPat: "https://*.example.com/*" }); + pass({ + oldPat: ["http://*.example.com/*"], + newPat: "http://subdomain.example.com/*", + }); + pass({ + oldPat: ["http://*.sub.example.com/*"], + newPat: "http://sub.example.com/*", + }); + pass({ + oldPat: ["http://*.sub.example.com/*"], + newPat: "http://sec.sub.example.com/*", + }); + pass({ + oldPat: ["http://www.example.com/*"], + newPat: "http://www.example.com/path/*", + }); + pass({ + oldPat: ["http://www.example.com/path/*"], + newPat: "http://www.example.com/*", + }); + + fail({ oldPat: ["*://*/*"], newPat: "<all_urls>" }); + fail({ oldPat: ["*://*/*"], newPat: "ftp://*/*" }); + fail({ oldPat: ["*://*/*"], newPat: "file://*/*" }); + + fail({ oldPat: ["http://example.com/*"], newPat: "*://example.com/*" }); + fail({ oldPat: ["http://example.com/*"], newPat: "https://example.com/*" }); + fail({ + oldPat: ["http://example.com/*"], + newPat: "http://otherexample.com/*", + }); + fail({ oldPat: ["http://example.com/*"], newPat: "http://*.example.com/*" }); + fail({ + oldPat: ["http://example.com/*"], + newPat: "http://subdomain.example.com/*", + }); + + fail({ + oldPat: ["http://subdomain.example.com/*"], + newPat: "http://example.com/*", + }); + fail({ + oldPat: ["http://subdomain.example.com/*"], + newPat: "http://*.example.com/*", + }); + fail({ + oldPat: ["http://sub.example.com/*"], + newPat: "http://*.sub.example.com/*", + }); + + fail({ oldPat: ["ws://example.com/*"], newPat: "wss://example.com/*" }); + fail({ oldPat: ["http://example.com/*"], newPat: "ws://example.com/*" }); + fail({ oldPat: ["https://example.com/*"], newPat: "wss://example.com/*" }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js new file mode 100644 index 0000000000..8b627a0ee9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js @@ -0,0 +1,286 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const NS_ERROR_DOM_QUOTA_EXCEEDED_ERR = 0x80530016; + +XPCOMUtils.defineLazyServiceGetter( + this, + "StorageSyncService", + "@mozilla.org/extensions/storage/sync;1", + "nsIInterfaceRequestor" +); + +function promisify(func, ...params) { + return new Promise((resolve, reject) => { + let changes = []; + func(...params, { + QueryInterface: ChromeUtils.generateQI([ + "mozIExtensionStorageListener", + "mozIExtensionStorageCallback", + "mozIBridgedSyncEngineCallback", + "mozIBridgedSyncEngineApplyCallback", + ]), + onChanged(extId, json) { + changes.push({ extId, changes: JSON.parse(json) }); + }, + handleSuccess(value) { + resolve({ + changes, + value: typeof value == "string" ? JSON.parse(value) : value, + }); + }, + handleError(code, message) { + reject(Components.Exception(message, code)); + }, + }); + }); +} + +add_task(async function setup_storage_sync() { + // So that we can write to the profile directory. + do_get_profile(); +}); + +add_task(async function test_storage_sync_service() { + const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + { + let { changes, value } = await promisify( + service.set, + "ext-1", + JSON.stringify({ + hi: "hello! 💖", + bye: "adiós", + }) + ); + deepEqual( + changes, + [ + { + extId: "ext-1", + changes: { + hi: { + newValue: "hello! 💖", + }, + bye: { + newValue: "adiós", + }, + }, + }, + ], + "`set` should notify listeners about changes" + ); + ok(!value, "`set` should not return a value"); + } + + { + let { changes, value } = await promisify( + service.get, + "ext-1", + JSON.stringify(["hi"]) + ); + deepEqual(changes, [], "`get` should not notify listeners"); + deepEqual( + value, + { + hi: "hello! 💖", + }, + "`get` with key should return value" + ); + + let { value: allValues } = await promisify(service.get, "ext-1", "null"); + deepEqual( + allValues, + { + hi: "hello! 💖", + bye: "adiós", + }, + "`get` without a key should return all values" + ); + } + + { + await promisify( + service.set, + "ext-2", + JSON.stringify({ + hi: "hola! 👋", + }) + ); + await promisify(service.clear, "ext-1"); + let { value: allValues } = await promisify(service.get, "ext-1", "null"); + deepEqual(allValues, {}, "clear removed ext-1"); + + let { value: allValues2 } = await promisify(service.get, "ext-2", "null"); + deepEqual(allValues2, { hi: "hola! 👋" }, "clear didn't remove ext-2"); + // We need to clear data for ext-2 too, so later tests don't fail due to + // this data. + await promisify(service.clear, "ext-2"); + } +}); + +add_task(async function test_storage_sync_bridged_engine() { + const area = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + + info("Add some local items"); + await promisify(area.set, "ext-1", JSON.stringify({ a: "abc" })); + await promisify(area.set, "ext-2", JSON.stringify({ b: "xyz" })); + + info("Start a sync"); + await promisify(engine.syncStarted); + + info("Store some incoming synced items"); + let incomingEnvelopesAsJSON = [ + { + id: "guidAAA", + modified: 0.1, + cleartext: JSON.stringify({ + id: "guidAAA", + extId: "ext-2", + data: JSON.stringify({ + c: 1234, + }), + }), + }, + { + id: "guidBBB", + modified: 0.1, + cleartext: JSON.stringify({ + id: "guidBBB", + extId: "ext-3", + data: JSON.stringify({ + d: "new! ✨", + }), + }), + }, + ].map(e => JSON.stringify(e)); + await promisify(area.storeIncoming, incomingEnvelopesAsJSON); + + info("Merge"); + // Three levels of JSON wrapping: each outgoing envelope, the cleartext in + // each envelope, and the extension storage data in each cleartext. + let { value: outgoingEnvelopesAsJSON } = await promisify(area.apply); + let outgoingEnvelopes = outgoingEnvelopesAsJSON.map(json => JSON.parse(json)); + let parsedCleartexts = outgoingEnvelopes.map(e => JSON.parse(e.cleartext)); + let parsedData = parsedCleartexts.map(c => JSON.parse(c.data)); + + let { changes } = await promisify( + area.QueryInterface(Ci.mozISyncedExtensionStorageArea) + .fetchPendingSyncChanges + ); + deepEqual( + changes, + [ + { + extId: "ext-2", + changes: { + c: { newValue: 1234 }, + }, + }, + { + extId: "ext-3", + changes: { + d: { newValue: "new! ✨" }, + }, + }, + ], + "Should return pending synced changes for observers" + ); + + // ext-1 doesn't exist remotely yet, so the Rust sync layer will generate + // a GUID for it. We don't know what it is, so we find it by the extension + // ID. + let ext1Index = parsedCleartexts.findIndex(c => c.extId == "ext-1"); + greater(ext1Index, -1, "Should find envelope for ext-1"); + let ext1Guid = outgoingEnvelopes[ext1Index].id; + + // ext-2 has a remote GUID that we set in the test above. + let ext2Index = outgoingEnvelopes.findIndex(c => c.id == "guidAAA"); + greater(ext2Index, -1, "Should find envelope for ext-2"); + + equal(outgoingEnvelopes.length, 2, "Should upload ext-1 and ext-2"); + equal( + ext1Guid, + parsedCleartexts[ext1Index].id, + "ext-1 ID in envelope should match cleartext" + ); + deepEqual( + parsedData[ext1Index], + { + a: "abc", + }, + "Should upload new data for ext-1" + ); + equal( + outgoingEnvelopes[ext2Index].id, + parsedCleartexts[ext2Index].id, + "ext-2 ID in envelope should match cleartext" + ); + deepEqual( + parsedData[ext2Index], + { + b: "xyz", + c: 1234, + }, + "Should merge local and remote data for ext-2" + ); + + info("Mark all extensions as uploaded"); + await promisify(engine.setUploaded, 0, [ext1Guid, "guidAAA"]); + + info("Finish sync"); + await promisify(engine.syncFinished); + + // Try fetching values for the remote-only extension we just synced. + let { value: ext3Value } = await promisify(area.get, "ext-3", "null"); + deepEqual( + ext3Value, + { + d: "new! ✨", + }, + "Should return new keys for ext-3" + ); + + info("Try applying a second time"); + let secondApply = await promisify(area.apply); + deepEqual(secondApply.value, {}, "Shouldn't merge anything on second apply"); + + info("Wipe all items"); + await promisify(engine.wipe); + + for (let extId of ["ext-1", "ext-2", "ext-3"]) { + // `get` always returns an object, even if there are no keys for the + // extension ID. + let { value } = await promisify(area.get, extId, "null"); + deepEqual(value, {}, `Wipe should remove all values for ${extId}`); + } +}); + +add_task(async function test_storage_sync_quota() { + const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + await promisify(engine.wipe); + await promisify(service.set, "ext-1", JSON.stringify({ x: "hi" })); + await promisify(service.set, "ext-1", JSON.stringify({ longer: "value" })); + + let { value: v1 } = await promisify(service.getBytesInUse, "ext-1", '"x"'); + Assert.equal(v1, 5); // key len without quotes, value len with quotes. + let { value: v2 } = await promisify(service.getBytesInUse, "ext-1", "null"); + // 5 from 'x', plus 'longer' (6 for key, 7 for value = 13) = 18. + Assert.equal(v2, 18); + + // Now set something greater than our quota. + await Assert.rejects( + promisify( + service.set, + "ext-1", + JSON.stringify({ + big: "x".repeat(Ci.mozIExtensionStorageArea.SYNC_QUOTA_BYTES), + }) + ), + ex => ex.result == NS_ERROR_DOM_QUOTA_EXCEEDED_ERR, + "should reject with NS_ERROR_DOM_QUOTA_EXCEEDED_ERR" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js new file mode 100644 index 0000000000..78d61d4b29 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js @@ -0,0 +1,209 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { newURI } = Services.io; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, +}); + +add_task(async function test_WebExtensinonContentScript_url_matching() { + let contentScript = new WebExtensionContentScript(policy, { + matches: new MatchPatternSet(["http://foo.com/bar", "*://bar.com/baz/*"]), + + excludeMatches: new MatchPatternSet(["*://bar.com/baz/quux"]), + + includeGlobs: ["*flerg*", "*.com/bar", "*/quux"].map( + glob => new MatchGlob(glob) + ), + + excludeGlobs: ["*glorg*"].map(glob => new MatchGlob(glob)), + }); + + ok( + contentScript.matchesURI(newURI("http://foo.com/bar")), + "Simple matches include should match" + ); + + ok( + contentScript.matchesURI(newURI("https://bar.com/baz/xflergx")), + "Simple matches include should match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/xx")), + "Failed includeGlobs match pattern should not match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/quux")), + "Excluded match pattern should not match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/xflergxglorgx")), + "Excluded match glob should not match" + ); +}); + +async function loadURL(url) { + let requests = new Map(); + + function requestObserver(request) { + request.QueryInterface(Ci.nsIChannel); + if (request.isDocument) { + requests.set(request.name, request); + } + } + + Services.obs.addObserver(requestObserver, "http-on-examine-response"); + + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + Services.obs.removeObserver(requestObserver, "http-on-examine-response"); + + return { contentPage, requests }; +} + +add_task(async function test_WebExtensinonContentScript_frame_matching() { + if (AppConstants.platform == "linux") { + // The windowless browser currently does not load correctly on Linux on + // infra. + return; + } + + let baseURL = `http://example.com/data`; + let urls = { + topLevel: `${baseURL}/file_toplevel.html`, + iframe: `${baseURL}/file_iframe.html`, + srcdoc: "about:srcdoc", + aboutBlank: "about:blank", + }; + + let { contentPage, requests } = await loadURL(urls.topLevel); + + let tests = [ + { + matches: ["http://example.com/data/*"], + contentScript: {}, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + frameID: 0, + }, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + allFrames: true, + }, + topLevel: true, + iframe: true, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + allFrames: true, + matchAboutBlank: true, + }, + topLevel: true, + iframe: true, + aboutBlank: true, + srcdoc: true, + }, + + { + matches: ["http://foo.com/data/*"], + contentScript: { + allFrames: true, + matchAboutBlank: true, + }, + topLevel: false, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + ]; + + // matchesWindowGlobal tests against content frames + await contentPage.spawn({ tests, urls }, args => { + this.windows = new Map(); + this.windows.set(this.content.location.href, this.content); + for (let c of Array.from(this.content.frames)) { + this.windows.set(c.location.href, c); + } + this.policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + }); + + let tests = args.tests.map(t => { + t.contentScript.matches = new MatchPatternSet(t.matches); + t.script = new WebExtensionContentScript(this.policy, t.contentScript); + return t; + }); + for (let [i, test] of tests.entries()) { + for (let [frame, url] of Object.entries(args.urls)) { + let should = test[frame] ? "should" : "should not"; + let wgc = this.windows.get(url).windowGlobalChild; + Assert.equal( + test.script.matchesWindowGlobal(wgc), + test[frame], + `Script ${i} ${should} match the ${frame} frame` + ); + } + } + }); + + // Parent tests against loadInfo + tests = tests.map(t => { + t.contentScript.matches = new MatchPatternSet(t.matches); + t.script = new WebExtensionContentScript(policy, t.contentScript); + return t; + }); + + for (let [i, test] of tests.entries()) { + for (let [frame, url] of Object.entries(urls)) { + let should = test[frame] ? "should" : "should not"; + + if (url.startsWith("http")) { + let request = requests.get(url); + + equal( + test.script.matchesLoadInfo(request.URI, request.loadInfo), + test[frame], + `Script ${i} ${should} match the request LoadInfo for ${frame} frame` + ); + } + } + } + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js new file mode 100644 index 0000000000..75c6edd9c4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js @@ -0,0 +1,376 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { newURI } = Services.io; + +add_task(async function test_WebExtensionPolicy() { + const id = "foo@bar.baz"; + const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + + localizeCallback(str) { + return `<${str}>`; + }, + + allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], { + ignorePath: true, + }), + permissions: ["<all_urls>"], + webAccessibleResources: ["/foo/*", "/bar.baz"].map( + glob => new MatchGlob(glob) + ), + }); + + equal(policy.active, false, "Active attribute should initially be false"); + + // GetURL + + equal( + policy.getURL(), + mozExtURL, + "getURL() should return the correct root URL" + ); + equal( + policy.getURL("path/foo.html"), + `${mozExtURL}path/foo.html`, + "getURL(path) should return the correct URL" + ); + + // Permissions + + deepEqual( + policy.permissions, + ["<all_urls>"], + "Initial permissions should be correct" + ); + + ok( + policy.hasPermission("<all_urls>"), + "hasPermission should match existing permission" + ); + ok( + !policy.hasPermission("history"), + "hasPermission should not match nonexistent permission" + ); + + Assert.throws( + () => { + policy.permissions[0] = "foo"; + }, + TypeError, + "Permissions array should be frozen" + ); + + policy.permissions = ["history"]; + deepEqual( + policy.permissions, + ["history"], + "Permissions should be updateable as a set" + ); + + ok( + policy.hasPermission("history"), + "hasPermission should match existing permission" + ); + ok( + !policy.hasPermission("<all_urls>"), + "hasPermission should not match nonexistent permission" + ); + + // Origins + + ok( + policy.canAccessURI(newURI("http://foo.bar/quux")), + "Should be able to access permitted URI" + ); + ok( + policy.canAccessURI(newURI("https://x.baz/foo")), + "Should be able to access permitted URI" + ); + + ok( + !policy.canAccessURI(newURI("https://foo.bar/quux")), + "Should not be able to access non-permitted URI" + ); + + policy.allowedOrigins = new MatchPatternSet(["https://foo.bar/"], { + ignorePath: true, + }); + + ok( + policy.canAccessURI(newURI("https://foo.bar/quux")), + "Should be able to access updated permitted URI" + ); + ok( + !policy.canAccessURI(newURI("https://x.baz/foo")), + "Should not be able to access removed permitted URI" + ); + + // Web-accessible resources + + ok( + policy.isPathWebAccessible("/foo/bar"), + "Web-accessible glob should be web-accessible" + ); + ok( + policy.isPathWebAccessible("/bar.baz"), + "Web-accessible path should be web-accessible" + ); + ok( + !policy.isPathWebAccessible("/bar.baz/quux"), + "Non-web-accessible path should not be web-accessible" + ); + + // Localization + + equal( + policy.localize("foo"), + "<foo>", + "Localization callback should work as expected" + ); + + // Protocol and lookups. + + let proto = Services.io + .getProtocolHandler("moz-extension", uuid) + .QueryInterface(Ci.nsISubstitutingProtocolHandler); + + deepEqual( + WebExtensionPolicy.getActiveExtensions(), + [], + "Should have no active extensions" + ); + equal( + WebExtensionPolicy.getByID(id), + null, + "ID lookup should not return extension when not active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + null, + "Hostname lookup should not return extension when not active" + ); + Assert.throws( + () => proto.resolveURI(mozExtURI), + /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active" + ); + + policy.active = true; + equal(policy.active, true, "Active attribute should be updated"); + + let exts = WebExtensionPolicy.getActiveExtensions(); + equal(exts.length, 1, "Should have one active extension"); + equal(exts[0], policy, "Should have the correct active extension"); + + equal( + WebExtensionPolicy.getByID(id), + policy, + "ID lookup should return extension when active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + policy, + "Hostname lookup should return extension when active" + ); + + equal( + proto.resolveURI(mozExtURI), + baseURL, + "URL should resolve correctly while active" + ); + + policy.active = false; + equal(policy.active, false, "Active attribute should be updated"); + + deepEqual( + WebExtensionPolicy.getActiveExtensions(), + [], + "Should have no active extensions" + ); + equal( + WebExtensionPolicy.getByID(id), + null, + "ID lookup should not return extension when not active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + null, + "Hostname lookup should not return extension when not active" + ); + Assert.throws( + () => proto.resolveURI(mozExtURI), + /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active" + ); + + // Conflicting policies. + + // This asserts in debug builds, so only test in non-debug builds. + if (!AppConstants.DEBUG) { + policy.active = true; + + let attrs = [ + { id, uuid }, + { id, uuid: "d916886c-cfdf-482e-b7b1-d7f5b0facfa5" }, + { id: "foo@quux", uuid }, + ]; + + // eslint-disable-next-line no-shadow + for (let { id, uuid } of attrs) { + let policy2 = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL: "file://bar/", + + localizeCallback() {}, + + allowedOrigins: new MatchPatternSet([]), + }); + + Assert.throws( + () => { + policy2.active = true; + }, + /NS_ERROR_UNEXPECTED/, + `Should not be able to activate conflicting policy: ${id} ${uuid}` + ); + } + + policy.active = false; + } +}); + +add_task(async function test_WebExtensionPolicy_registerContentScripts() { + const id = "foo@bar.baz"; + const uuid = "77a7b9d3-e73c-4cf3-97fb-1824868fe00f"; + + const id2 = "foo-2@bar.baz"; + const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd"; + + const baseURL = "file:///foo/"; + + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURL2 = `moz-extension://${uuid2}/`; + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + + let policy2 = new WebExtensionPolicy({ + id: id2, + mozExtensionHostname: uuid2, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + + let script1 = new WebExtensionContentScript(policy, { + run_at: "document_end", + js: [`${mozExtURL}/registered-content-script.js`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + let script2 = new WebExtensionContentScript(policy, { + run_at: "document_end", + css: [`${mozExtURL}/registered-content-style.css`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + let script3 = new WebExtensionContentScript(policy2, { + run_at: "document_end", + css: [`${mozExtURL2}/registered-content-style.css`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + deepEqual( + policy.contentScripts, + [], + "The policy contentScripts is initially empty" + ); + + policy.registerContentScript(script1); + + deepEqual( + policy.contentScripts, + [script1], + "script1 has been added to the policy contentScripts" + ); + + Assert.throws( + () => policy.registerContentScript(script1), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script more than once" + ); + + Assert.throws( + () => policy.registerContentScript(script3), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script related to " + + "a different extension" + ); + + Assert.throws( + () => policy.unregisterContentScript(script3), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script related to " + + "a different extension" + ); + + deepEqual( + policy.contentScripts, + [script1], + "script1 has not been added twice" + ); + + policy.registerContentScript(script2); + + deepEqual( + policy.contentScripts, + [script1, script2], + "script2 has the last item of the policy contentScripts array" + ); + + policy.unregisterContentScript(script1); + + deepEqual( + policy.contentScripts, + [script2], + "script1 has been removed from the policy contentscripts" + ); + + Assert.throws( + () => policy.unregisterContentScript(script1), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script more than once" + ); + + deepEqual( + policy.contentScripts, + [script2], + "the policy contentscripts is unmodified when unregistering an unknown contentScript" + ); + + policy.unregisterContentScript(script2); + + deepEqual( + policy.contentScripts, + [], + "script2 has been removed from the policy contentScripts" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js new file mode 100644 index 0000000000..a6d22e8703 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js @@ -0,0 +1,20 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function change_remote() { + let remote = Services.prefs.getBoolPref("extensions.webextensions.remote"); + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + remote, + "value of useRemoteWebExtensions matches the pref" + ); + + Services.prefs.setBoolPref("extensions.webextensions.remote", !remote); + + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + remote, + "value of useRemoteWebExtensions is still the same after changing the pref" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js new file mode 100644 index 0000000000..0b24cc4c50 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js @@ -0,0 +1,278 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +const ADDON_ID = "test@web.extension"; + +const aps = Cc["@mozilla.org/addons/policy-service;1"].getService( + Ci.nsIAddonPolicyService +); + +const v2_csp = Preferences.get( + "extensions.webextensions.base-content-security-policy" +); +const v3_csp = Preferences.get( + "extensions.webextensions.base-content-security-policy.v3" +); + +add_task(async function test_invalid_addon_csp() { + await Assert.throws( + () => aps.getBaseCSP("invalid@missing"), + /NS_ERROR_ILLEGAL_VALUE/, + "no base csp for non-existent addon" + ); + await Assert.throws( + () => aps.getExtensionPageCSP("invalid@missing"), + /NS_ERROR_ILLEGAL_VALUE/, + "no extension page csp for non-existent addon" + ); +}); + +add_task(async function test_policy_csp() { + equal( + aps.defaultCSP, + Preferences.get("extensions.webextensions.default-content-security-policy"), + "Expected default CSP value" + ); + + const CUSTOM_POLICY = + "script-src: 'self' https://xpcshell.test.custom.csp; object-src: 'none'"; + + let tests = [ + { + name: "manifest version 2, no custom policy", + policyData: {}, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest version 2, no custom policy", + policyData: { + manifestVersion: 2, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "version 2 custom extension policy", + policyData: { + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + { + name: "manifest version 2 set, custom extension policy", + policyData: { + manifestVersion: 2, + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + { + name: "manifest version 3, no custom policy", + policyData: { + manifestVersion: 3, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest 3 version set, custom extensionPage policy", + policyData: { + manifestVersion: 3, + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + ]; + + let policy = null; + + function setExtensionCSP({ manifestVersion, extensionPageCSP }) { + if (policy) { + policy.active = false; + } + + policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: ADDON_ID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + + manifestVersion, + extensionPageCSP, + }); + + policy.active = true; + } + + for (let test of tests) { + info(test.name); + setExtensionCSP(test.policyData); + equal( + aps.getBaseCSP(ADDON_ID), + test.policyData.manifestVersion == 3 ? v3_csp : v2_csp, + "baseCSP is correct" + ); + equal( + aps.getExtensionPageCSP(ADDON_ID), + test.expectedPolicy, + "extensionPageCSP is correct" + ); + } +}); + +add_task(async function test_extension_csp() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + + ExtensionTestUtils.failOnSchemaWarnings(false); + + let extension_pages = "script-src 'self'; object-src 'none'; img-src 'none'"; + + let tests = [ + { + name: "manifest_v2 invalid csp results in default csp used", + manifest: { + content_security_policy: `script-src 'none'`, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v2 allows https protocol", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://*; object-src 'self'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v2 allows unsafe-eval", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'unsafe-eval'; object-src 'self'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 invalid csp results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'none'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 forbidden protocol results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://*; object-src 'self'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 forbidden eval results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'unsafe-eval'; object-src 'self'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 allows localhost", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://localhost; object-src 'self'`, + }, + }, + expectedPolicy: `script-src 'self' https://localhost; object-src 'self'`, + }, + { + name: "manifest_v3 allows 127.0.0.1", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://127.0.0.1; object-src 'self'`, + }, + }, + expectedPolicy: `script-src 'self' https://127.0.0.1; object-src 'self'`, + }, + { + name: "manifest_v2 csp", + manifest: { + manifest_version: 2, + content_security_policy: extension_pages, + }, + expectedPolicy: extension_pages, + }, + { + name: "manifest_v2 with no csp, expect default", + manifest: { + manifest_version: 2, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 used with no csp, expect default", + manifest: { + manifest_version: 3, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 used with v2 syntax", + manifest: { + manifest_version: 3, + content_security_policy: extension_pages, + }, + expectedPolicy: extension_pages, + }, + { + name: "manifest_v3 syntax used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages, + }, + }, + expectedPolicy: extension_pages, + }, + ]; + + for (let test of tests) { + info(test.name); + let extension = ExtensionTestUtils.loadExtension({ + manifest: test.manifest, + }); + await extension.startup(); + let policy = WebExtensionPolicy.getByID(extension.id); + equal( + policy.baseCSP, + test.manifest.manifest_version == 3 ? v3_csp : v2_csp, + "baseCSP is correct" + ); + equal( + policy.extensionPageCSP, + test.expectedPolicy, + "extensionPageCSP is correct." + ); + await extension.unload(); + } + + ExtensionTestUtils.failOnSchemaWarnings(true); + + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_validator.js b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js new file mode 100644 index 0000000000..fb494f3da2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js @@ -0,0 +1,298 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const cps = Cc["@mozilla.org/addons/content-policy;1"].getService( + Ci.nsIAddonContentPolicy +); + +add_task(async function test_csp_validator_flags() { + let checkPolicy = (policy, flags, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP(policy, flags); + equal(result, expectedResult); + }; + + let flags = Ci.nsIAddonContentPolicy; + + checkPolicy( + "default-src 'self'; script-src 'self' http://localhost", + 0, + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "localhost disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' http://localhost", + flags.CSP_ALLOW_LOCALHOST, + null, + "localhost allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval'", + 0, + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword", + "eval disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval'", + flags.CSP_ALLOW_EVAL, + null, + "eval allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' https://example.com", + 0, + "\u2018script-src\u2019 directive contains a forbidden https: protocol source", + "remote disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' https://example.com", + flags.CSP_ALLOW_REMOTE, + null, + "remote allowed" + ); +}); + +add_task(async function test_csp_validator() { + let checkPolicy = (policy, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP( + policy, + Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY + ); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self'; object-src 'self';", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash} 'unsafe-eval'; ` + + `object-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash}`, + null + ); + + checkPolicy( + "", + "Policy is missing a required \u2018script-src\u2019 directive" + ); + + checkPolicy( + "object-src 'none';", + "Policy is missing a required \u2018script-src\u2019 directive" + ); + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src or object-src" + ); + + checkPolicy( + "default-src 'self'; script-src 'self'", + null, + "A valid default-src should count as a valid script-src or object-src" + ); + + checkPolicy( + "default-src 'self'; object-src 'self'", + null, + "A valid default-src should count as a valid script-src or object-src" + ); + + checkPolicy( + "default-src 'self'; script-src http://example.com", + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "A valid default-src should not allow an invalid script-src directive" + ); + + checkPolicy( + "default-src 'self'; object-src http://example.com", + "\u2018object-src\u2019 directive contains a forbidden http: protocol source", + "A valid default-src should not allow an invalid object-src directive" + ); + + checkPolicy( + "script-src 'self';", + "Policy is missing a required \u2018object-src\u2019 directive" + ); + + checkPolicy( + "script-src 'none'; object-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy("script-src 'self'; object-src 'none';", null); + + checkPolicy( + "script-src 'self' 'unsafe-inline'; object-src 'self';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + // Localhost is always valid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + checkPolicy(`script-src 'self' ${src}; object-src 'none';`, null); + } + + let directives = ["script-src", "object-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let src of ["https://*", "https://*.blogspot.com", "https://*"]) { + checkPolicy( + `${directive} 'self' ${src}; ${other} 'self';`, + `https: wildcard sources in \u2018${directive}\u2019 directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)` + ); + } + + for (let protocol of ["http", "https"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives` + ); + } + + checkPolicy( + `${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source` + ); + + for (let protocol of ["ftp", "meh"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + checkPolicy( + `${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword` + ); + } +}); + +add_task(async function test_csp_validator_extension_pages() { + let checkPolicy = (policy, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP( + policy, + Ci.nsIAddonContentPolicy.CSP_ALLOW_LOCALHOST + ); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self'; object-src 'self';", null); + checkPolicy("script-src 'self'; object-src 'self'; worker-src 'none'", null); + checkPolicy("script-src 'self'; object-src 'none'; worker-src 'self'", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}; ` + + `object-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}`, + null + ); + + for (let policy of ["", "object-src 'none';", "worker-src 'none';"]) { + checkPolicy( + policy, + "Policy is missing a required \u2018script-src\u2019 directive" + ); + } + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src or object-src" + ); + + for (let directive of ["script-src", "object-src", "worker-src"]) { + checkPolicy( + `default-src 'self'; ${directive} 'self'`, + null, + `A valid default-src should count as a valid ${directive}` + ); + checkPolicy( + `default-src 'self'; ${directive} http://example.com`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source`, + `A valid default-src should not allow an invalid ${directive} directive` + ); + } + + checkPolicy( + "script-src 'self';", + "Policy is missing a required \u2018object-src\u2019 directive" + ); + + checkPolicy( + "script-src 'none'; object-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy("script-src 'self'; object-src 'none';", null); + + checkPolicy( + "script-src 'self' 'unsafe-inline'; object-src 'self';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + checkPolicy( + "script-src 'self' 'unsafe-eval'; object-src 'self';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword" + ); + + // Localhost is always valid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + checkPolicy(`script-src 'self' ${src}; object-src 'none';`, null); + } + + let directives = ["script-src", "object-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let protocol of ["http", "https"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives` + ); + } + + checkPolicy( + `${directive} 'self' https://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden https: protocol source` + ); + + checkPolicy( + `${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source` + ); + + for (let protocol of ["ftp", "meh"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + checkPolicy( + `${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js new file mode 100644 index 0000000000..20ffb71d18 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { MessageManagerProxy } = ChromeUtils.import( + "resource://gre/modules/MessageManagerProxy.jsm" +); +const { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +class TestMessageManagerProxy extends MessageManagerProxy { + constructor(contentPage, identifier) { + super(contentPage.browser); + this.identifier = identifier; + this.contentPage = contentPage; + this.deferred = null; + } + + // Registers message listeners. Call dispose() once you've finished. + async setupPingPongListeners() { + await this.contentPage.loadFrameScript(`() => { + this.addMessageListener("test:MessageManagerProxy:Ping", ({data}) => { + this.sendAsyncMessage("test:MessageManagerProxy:Pong", "${this.identifier}:" + data); + }); + }`); + + // Register the listener here instead of during testPingPong, to make sure + // that the listener is correctly registered during the whole test. + this.addMessageListener("test:MessageManagerProxy:Pong", event => { + ok( + this.deferred, + `[${this.identifier}] expected to be waiting for ping-pong` + ); + this.deferred.resolve(event.data); + this.deferred = null; + }); + } + + async testPingPong(description) { + equal(this.deferred, null, "should not be waiting for a message"); + this.deferred = PromiseUtils.defer(); + this.sendAsyncMessage("test:MessageManagerProxy:Ping", description); + let result = await this.deferred.promise; + equal(result, `${this.identifier}:${description}`, "Expected ping-pong"); + } +} + +// Tests that MessageManagerProxy continues to proxy messages after docshells +// have been swapped. +add_task(async function test_message_after_swapdocshells() { + let page1 = await ExtensionTestUtils.loadContentPage("about:blank"); + let page2 = await ExtensionTestUtils.loadContentPage("about:blank"); + + let testProxyOne = new TestMessageManagerProxy(page1, "page1"); + let testProxyTwo = new TestMessageManagerProxy(page2, "page2"); + + await testProxyOne.setupPingPongListeners(); + await testProxyTwo.setupPingPongListeners(); + + await testProxyOne.testPingPong("after setup (to 1)"); + await testProxyTwo.testPingPong("after setup (to 2)"); + + page1.browser.swapDocShells(page2.browser); + + await testProxyOne.testPingPong("after docshell swap (to 1)"); + await testProxyTwo.testPingPong("after docshell swap (to 2)"); + + // Swap again to verify that listeners are repeatedly moved when needed. + page1.browser.swapDocShells(page2.browser); + + await testProxyOne.testPingPong("after another docshell swap (to 1)"); + await testProxyTwo.testPingPong("after another docshell swap (to 2)"); + + // Verify that dispose() works regardless of the browser's validity. + await testProxyOne.dispose(); + await page1.close(); + await page2.close(); + await testProxyTwo.dispose(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js new file mode 100644 index 0000000000..4a23b65264 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js @@ -0,0 +1,21 @@ +"use strict"; + +add_task(async function test_api_restricted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { id: "activityLog-permission@tests.mozilla.org" }, + }, + permissions: ["activityLog"], + }, + async background() { + browser.test.assertEq( + undefined, + browser.activityLog, + "activityLog is privileged" + ); + }, + }); + await extension.startup(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js new file mode 100644 index 0000000000..bc4e0409cb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js @@ -0,0 +1,160 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_private_field_xrays() { + async function contentScript() { + let node = window.document.createElement("div"); + + class Base { + constructor(o) { + return o; + } + } + + class A extends Base { + #x = 5; + static gx(o) { + return o.#x; + } + static sx(o, v) { + o.#x = v; + } + } + + browser.test.log(A.toString()); + + // Stamp node with A's private field. + new A(node); + + browser.test.log("stamped"); + + browser.test.assertEq( + A.gx(node), + 5, + "We should be able to see our expando private field" + ); + browser.test.log("Read"); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Underlying object should not have our private field" + ); + + browser.test.log("threw"); + window.frames[0].document.adoptNode(node); + browser.test.log("adopted"); + browser.test.assertEq( + A.gx(node), + 5, + "Adoption should not change expando private field" + ); + browser.test.log("read"); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Adoption should really not change expandos private fields" + ); + browser.test.log("threw2"); + + // Repeat but now with an object that has a reference from the + // window it's being cloned into. + node = window.document.createElement("div"); + // Stamp node with A's private field. + new A(node); + A.sx(node, 6); + + browser.test.assertEq( + A.gx(node), + 6, + "We should be able to see our expando (2)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Underlying object should not have exxpando. (2)" + ); + + window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject; + window.frames[0].document.adoptNode(node); + + browser.test.assertEq( + A.gx(node), + 6, + "We should be able to see our expando (3)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Underlying object should not have exxpando. (3)" + ); + + // Repeat once more, now with an expando that refers to the object itself + node = window.document.createElement("div"); + new A(node); + A.sx(node, node); + + browser.test.assertEq( + A.gx(node), + node, + "We should be able to see our self-referential expando (4)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Underlying object should not have exxpando. (4)" + ); + + window.frames[0].document.adoptNode(node); + + browser.test.assertEq( + A.gx(node), + node, + "Adoption should not change our self-referential expando (4)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Adoption should not change underlying object. (4)" + ); + + // And test what happens if we now set document.domain and cause + // wrapper remapping. + let doc = window.frames[0].document; + // eslint-disable-next-line no-self-assign + doc.domain = doc.domain; + + browser.test.notifyPass("privateFieldXRayAdoption"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_toplevel.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + + await extension.awaitFinish("privateFieldXRayAdoption"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js new file mode 100644 index 0000000000..9655c157d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js @@ -0,0 +1,129 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_xrays() { + async function contentScript() { + let node = window.document.createElement("div"); + node.expando = 5; + + browser.test.assertEq( + node.expando, + 5, + "We should be able to see our expando" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our expando" + ); + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + 5, + "Adoption should not change expandos" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change expandos" + ); + + // Repeat but now with an object that has a reference from the + // window it's being cloned into. + node = window.document.createElement("div"); + node.expando = 6; + + browser.test.assertEq( + node.expando, + 6, + "We should be able to see our expando (2)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our expando (2)" + ); + + window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject; + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + 6, + "Adoption should not change expandos (2)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change expandos (2)" + ); + + // Repeat once more, now with an expando that refers to the object itself. + node = window.document.createElement("div"); + node.expando = node; + + browser.test.assertEq( + node.expando, + node, + "We should be able to see our self-referential expando (3)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our self-referential expando (3)" + ); + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + node, + "Adoption should not change self-referential expando (3)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change self-referential expando (3)" + ); + + // And test what happens if we now set document.domain and cause + // wrapper remapping. + let doc = window.frames[0].document; + // eslint-disable-next-line no-self-assign + doc.domain = doc.domain; + + browser.test.notifyPass("contentScriptAdoptionWithXrays"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_toplevel.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + + await extension.awaitFinish("contentScriptAdoptionWithXrays"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js new file mode 100644 index 0000000000..0751f7d573 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js @@ -0,0 +1,219 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_alarm_without_permissions() { + function backgroundScript() { + browser.test.assertTrue( + !browser.alarms, + "alarm API is not available when the alarm permission is not required" + ); + browser.test.notifyPass("alarms_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarms_permission"); + await extension.unload(); +}); + +add_task(async function test_alarm_clear_non_matching_name() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.create(ALARM_NAME, { when: Date.now() + 2000000 }); + + let wasCleared = await browser.alarms.clear(ALARM_NAME + "1"); + browser.test.assertFalse(wasCleared, "alarm was not cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(1, alarms.length, "alarm was not removed"); + browser.test.notifyPass("alarm-clear"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-clear"); + await extension.unload(); +}); + +add_task(async function test_alarm_get_and_clear_single_argument() { + async function backgroundScript() { + browser.alarms.create({ when: Date.now() + 2000000 }); + + let alarm = await browser.alarms.get(); + browser.test.assertEq("", alarm.name, "expected alarm returned"); + + let wasCleared = await browser.alarms.clear(); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "alarm was removed"); + + browser.test.notifyPass("alarm-single-arg"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-single-arg"); + await extension.unload(); +}); + +add_task(async function test_get_get_all_clear_all_alarms() { + async function backgroundScript() { + const ALARM_NAME = "test_alarm"; + + let suffixes = [0, 1, 2]; + + for (let suffix of suffixes) { + browser.alarms.create(ALARM_NAME + suffix, { + when: Date.now() + (suffix + 1) * 10000, + }); + } + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq( + suffixes.length, + alarms.length, + "expected number of alarms were found" + ); + alarms.forEach((alarm, index) => { + browser.test.assertEq( + ALARM_NAME + index, + alarm.name, + "alarm has the expected name" + ); + }); + + for (let suffix of suffixes) { + let alarm = await browser.alarms.get(ALARM_NAME + suffix); + browser.test.assertEq( + ALARM_NAME + suffix, + alarm.name, + "alarm has the expected name" + ); + browser.test.sendMessage(`get-${suffix}`); + } + + let wasCleared = await browser.alarms.clear(ALARM_NAME + suffixes[0]); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(2, alarms.length, "alarm was removed"); + + let alarm = await browser.alarms.get(ALARM_NAME + suffixes[0]); + browser.test.assertEq(undefined, alarm, "non-existent alarm is undefined"); + browser.test.sendMessage(`get-invalid`); + + wasCleared = await browser.alarms.clearAll(); + browser.test.assertTrue(wasCleared, "alarms were cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "no alarms exist"); + browser.test.sendMessage("clearAll"); + browser.test.sendMessage("clear"); + browser.test.sendMessage("getAll"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("getAll"), + extension.awaitMessage("get-0"), + extension.awaitMessage("get-1"), + extension.awaitMessage("get-2"), + extension.awaitMessage("clear"), + extension.awaitMessage("get-invalid"), + extension.awaitMessage("clearAll"), + ]); + await extension.unload(); +}); + +async function test_alarm_fires_with_options(alarmCreateOptions) { + info( + `Test alarms.create fires with options: ${JSON.stringify( + alarmCreateOptions + )}` + ); + + function backgroundScript(createOptions) { + let ALARM_NAME = "test_ext_alarms"; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq( + ALARM_NAME, + alarm.name, + "alarm has the expected name" + ); + clearTimeout(timer); + browser.test.notifyPass("alarms-create-with-options"); + }); + + browser.alarms.create(ALARM_NAME, createOptions); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired within expected time"); + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + browser.test.notifyFail("alarms-create-with-options"); + }, 10000); + } + + let extension = ExtensionTestUtils.loadExtension({ + // Pass the alarms.create options to the background page. + background: `(${backgroundScript})(${JSON.stringify(alarmCreateOptions)})`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarms-create-with-options"); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +} + +add_task(async function test_alarm_fires() { + Services.prefs.setBoolPref( + "privacy.resistFingerprinting.reduceTimerPrecision.jitter", + false + ); + + await test_alarm_fires_with_options({ delayInMinutes: 0.01 }); + await test_alarm_fires_with_options({ when: Date.now() + 1000 }); + await test_alarm_fires_with_options({ delayInMinutes: -10 }); + await test_alarm_fires_with_options({ when: Date.now() - 1000 }); + + Services.prefs.clearUserPref( + "privacy.resistFingerprinting.reduceTimerPrecision.jitter" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js new file mode 100644 index 0000000000..fe385004ba --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js @@ -0,0 +1,34 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_cleared_alarm_does_not_fire() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.fail("cleared alarm does not fire"); + browser.test.notifyFail("alarm-cleared"); + }); + browser.alarms.create(ALARM_NAME, { when: Date.now() + 1000 }); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + browser.test.notifyPass("alarm-cleared"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-cleared"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js new file mode 100644 index 0000000000..b78d6da649 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_periodic_alarm_fires() { + function backgroundScript() { + const ALARM_NAME = "test_ext_alarms"; + let count = 0; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq( + alarm.name, + ALARM_NAME, + "alarm has the expected name" + ); + if (count++ === 3) { + clearTimeout(timer); + browser.alarms.clear(ALARM_NAME).then(wasCleared => { + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyPass("alarm-periodic"); + }); + } + }); + + browser.alarms.create(ALARM_NAME, { periodInMinutes: 0.02 }); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired expected number of times"); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyFail("alarm-periodic"); + }, 30000); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-periodic"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js new file mode 100644 index 0000000000..0d7597fa5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_duplicate_alarm_name_replaces_alarm() { + function backgroundScript() { + let count = 0; + + browser.alarms.onAlarm.addListener(async alarm => { + browser.test.assertEq( + "replaced alarm", + alarm.name, + "Expected last alarm" + ); + browser.test.assertEq( + 0, + count++, + "duplicate named alarm replaced existing alarm" + ); + let results = await browser.alarms.getAll(); + + // "replaced alarm" is expected to be replaced with a non-repeating + // alarm, so it should not appear in the list of alarms. + browser.test.assertEq(1, results.length, "exactly one alarms exists"); + browser.test.assertEq( + "unrelated alarm", + results[0].name, + "remaining alarm has the expected name" + ); + + browser.test.notifyPass("alarm-duplicate"); + }); + + // Alarm that is so far in the future that it is never triggered. + browser.alarms.create("unrelated alarm", { delayInMinutes: 60 }); + // Alarm that repeats. + browser.alarms.create("replaced alarm", { + delayInMinutes: 1 / 60, + periodInMinutes: 1 / 60, + }); + // Before the repeating alarm is triggered, it is immediately replaced with + // a non-repeating alarm. + browser.alarms.create("replaced alarm", { delayInMinutes: 3 / 60 }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-duplicate"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js new file mode 100644 index 0000000000..4be29dc848 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js @@ -0,0 +1,76 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null +); +function getNextContext() { + return new Promise(resolve => { + Management.on("proxy-context-load", function listener(type, context) { + Management.off("proxy-context-load", listener); + resolve(context); + }); + }); +} + +add_task(async function test_storage_api_without_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + // Force API initialization. + try { + browser.storage.onChanged.addListener(() => {}); + } catch (e) { + // Ignore. + } + }, + + manifest: { + permissions: [], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + ok( + !("storage" in context.apiObj), + "The storage API should not be initialized" + ); + + await extension.unload(); +}); + +add_task(async function test_storage_api_with_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.storage.onChanged.addListener(() => {}); + }, + + manifest: { + permissions: ["storage"], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + equal( + typeof context.apiObj.storage, + "object", + "The storage API should be initialized" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js new file mode 100644 index 0000000000..a603b03a29 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js @@ -0,0 +1,35 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function testBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + window.location = + "http://example.com/data/file_privilege_escalation.html"; + }, + }); + + let awaitConsole = new Promise(resolve => { + Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + resolve(message); + } + }); + }); + + await extension.startup(); + + let message = await awaitConsole; + ok( + message.message.includes( + "WebExt Privilege Escalation: typeof(browser) = undefined" + ), + "Document does not have `browser` APIs." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js new file mode 100644 index 0000000000..ec9d9a6c43 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js @@ -0,0 +1,195 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { BrowserTestUtils } = ChromeUtils.import( + "resource://testing-common/BrowserTestUtils.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null +); + +// Crashes a <browser>'s remote process. +// Based on BrowserTestUtils.crashFrame. +function crashFrame(browser) { + if (!browser.isRemoteBrowser) { + // The browser should be remote, or the test runner would be killed. + throw new Error("<browser> must be remote"); + } + + // Trigger crash by sending a message to BrowserTestUtils actor. + BrowserTestUtils.sendAsyncMessage( + browser.browsingContext, + "BrowserTestUtils:CrashFrame", + {} + ); +} + +// Verifies that a delayed background page is not loaded when an extension is +// shut down during startup. +add_task(async function test_unload_extension_before_background_page_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + browser.test.sendMessage("background_startup_observed"); + }, + }); + + // Delayed startup are only enabled for browser (re)starts, so we need to + // install the extension first, and then unload it. + + await extension.startup(); + await extension.awaitMessage("background_startup_observed"); + + // Now the actual test: Unloading an extension before the startup has + // finished should interrupt the start-up and abort pending delayed loads. + info("Starting extension whose startup will be interrupted"); + ExtensionParent._resetStartupPromises(); + await promiseRestartManager(); + await extension.awaitStartup(); + + let extensionBrowserInsertions = 0; + let onExtensionBrowserInserted = () => ++extensionBrowserInsertions; + Management.on("extension-browser-inserted", onExtensionBrowserInserted); + + info("Unloading extension before the delayed background page starts loading"); + await extension.addon.disable(); + + // Re-enable the add-on to let enough time pass to load a whole background + // page. If at the end of this the original background page hasn't loaded, + // we can consider the test successful. + await extension.addon.enable(); + + // Trigger the notification that would load a background page. + info("Forcing pending delayed background page to load"); + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + + // This is the expected message from the re-enabled add-on. + await extension.awaitMessage("background_startup_observed"); + await extension.unload(); + + await promiseShutdownManager(); + ExtensionParent._resetStartupPromises(); + + Management.off("extension-browser-inserted", onExtensionBrowserInserted); + Assert.equal( + extensionBrowserInsertions, + 1, + "Extension browser should have been inserted only once" + ); +}); + +// Verifies that the "build" method of BackgroundPage in ext-backgroundPage.js +// does not deadlock when startup is interrupted by extension shutdown. +add_task(async function test_unload_extension_during_background_page_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + browser.test.sendMessage("background_starting"); + }, + }); + + // Delayed startup are only enabled for browser (re)starts, so we need to + // install the extension first, and then reload it. + await extension.startup(); + await extension.awaitMessage("background_starting"); + + ExtensionParent._resetStartupPromises(); + await promiseRestartManager(); + await extension.awaitStartup(); + + let bgStartupPromise = new Promise(resolve => { + function onBackgroundPageDone(eventName) { + extension.extension.off("background-page-started", onBackgroundPageDone); + extension.extension.off("background-page-aborted", onBackgroundPageDone); + + if (eventName === "background-page-aborted") { + info("Background page startup was interrupted"); + resolve("bg_aborted"); + } else { + info("Background page startup finished normally"); + resolve("bg_fully_loaded"); + } + } + extension.extension.on("background-page-started", onBackgroundPageDone); + extension.extension.on("background-page-aborted", onBackgroundPageDone); + }); + + let bgStartingPromise = new Promise(resolve => { + let backgroundLoadCount = 0; + let backgroundPageUrl = extension.extension.baseURI.resolve( + "_generated_background_page.html" + ); + + // Prevent the background page from actually loading. + Management.once("extension-browser-inserted", (eventName, browser) => { + // Intercept background page load. + let browserLoadURI = browser.loadURI; + browser.loadURI = function() { + Assert.equal(++backgroundLoadCount, 1, "loadURI should be called once"); + Assert.equal( + arguments[0], + backgroundPageUrl, + "Expected background page" + ); + // Reset to "about:blank" to not load the actual background page. + arguments[0] = "about:blank"; + browserLoadURI.apply(this, arguments); + + // And force the extension process to crash. + if (browser.isRemote) { + crashFrame(browser); + } else { + // If extensions are not running in out-of-process mode, then the + // non-remote process should not be killed (or the test runner dies). + // Remove <browser> instead, to simulate the immediate disconnection + // of the message manager (that would happen if the process crashed). + browser.remove(); + } + resolve(); + }; + }); + }); + + // Force background page to initialize. + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + await bgStartingPromise; + + await extension.unload(); + await promiseShutdownManager(); + + // This part is the regression test for bug 1501375. It verifies that the + // background building completes eventually. + // If it does not, then the next line will cause a timeout. + info("Waiting for background builder to finish"); + let bgLoadState = await bgStartupPromise; + Assert.equal(bgLoadState, "bg_aborted", "Startup should be interrupted"); + + ExtensionParent._resetStartupPromises(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js new file mode 100644 index 0000000000..cac574b8ca --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js @@ -0,0 +1,23 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_DOMContentLoaded_in_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + function reportListener(event) { + browser.test.sendMessage("eventname", event.type); + } + document.addEventListener("DOMContentLoaded", reportListener); + window.addEventListener("load", reportListener); + }, + }); + + await extension.startup(); + equal("DOMContentLoaded", await extension.awaitMessage("eventname")); + equal("load", await extension.awaitMessage("eventname")); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js new file mode 100644 index 0000000000..a22db9d582 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_reload_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + if (location.hash !== "#firstrun") { + browser.test.sendMessage("first run"); + location.hash = "#firstrun"; + browser.test.assertEq("#firstrun", location.hash); + location.reload(); + } else { + browser.test.notifyPass("second run"); + } + }, + }); + + await extension.startup(); + await extension.awaitMessage("first run"); + await extension.awaitFinish("second run"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js new file mode 100644 index 0000000000..eaf20827e5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PlacesTestUtils } = ChromeUtils.import( + "resource://testing-common/PlacesTestUtils.jsm" +); + +add_task(async function test_global_history() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background-loaded", location.href); + }, + }); + + await extension.startup(); + + let backgroundURL = await extension.awaitMessage("background-loaded"); + + await extension.unload(); + + let exists = await PlacesTestUtils.isPageInDB(backgroundURL); + ok(!exists, "Background URL should not be in history database"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js new file mode 100644 index 0000000000..5075e643be --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_background_incognito() { + info( + "Test background page incognito value with permanent private browsing enabled" + ); + + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + browser.test.assertEq( + window, + browser.extension.getBackgroundPage(), + "Caller should be able to access itself as a background page" + ); + browser.test.assertEq( + window, + await browser.runtime.getBackgroundPage(), + "Caller should be able to access itself as a background page" + ); + + browser.test.assertEq( + browser.extension.inIncognitoContext, + true, + "inIncognitoContext is true for permanent private browsing" + ); + + browser.test.notifyPass("incognito"); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("incognito"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js new file mode 100644 index 0000000000..aa0976434b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js @@ -0,0 +1,88 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let received_ports_number = 0; + + const expected_received_ports_number = 1; + + function countReceivedPorts(port) { + received_ports_number++; + + if (port.name == "check-results") { + browser.runtime.onConnect.removeListener(countReceivedPorts); + + browser.test.assertEq( + expected_received_ports_number, + received_ports_number, + "invalid connect should not create a port" + ); + + browser.test.notifyPass("runtime.connect invalid params"); + } + } + + browser.runtime.onConnect.addListener(countReceivedPorts); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); +} + +function senderScript() { + let detected_invalid_connect_params = 0; + + const invalid_connect_params = [ + // too many params + [ + "fake-extensions-id", + { name: "fake-conn-name" }, + "unexpected third params", + ], + // invalid params format + [{}, {}], + ["fake-extensions-id", "invalid-connect-info-format"], + ]; + const expected_detected_invalid_connect_params = + invalid_connect_params.length; + + function assertInvalidConnectParamsException(params) { + try { + browser.runtime.connect(...params); + } catch (e) { + detected_invalid_connect_params++; + browser.test.assertTrue( + e.toString().includes("Incorrect argument types for runtime.connect."), + "exception message is correct" + ); + } + } + for (let params of invalid_connect_params) { + assertInvalidConnectParamsException(params); + } + browser.test.assertEq( + expected_detected_invalid_connect_params, + detected_invalid_connect_params, + "all invalid runtime.connect params detected" + ); + + browser.runtime.connect(browser.runtime.id, { name: "check-results" }); +} + +let extensionData = { + background: backgroundScript, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, +}; + +add_task(async function test_backgroundRuntimeConnectParams() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("runtime.connect invalid params"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js new file mode 100644 index 0000000000..1c3180b1b6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + + browser.test.sendMessage("background-script-load"); + + let img = document.createElement("img"); + img.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + document.body.appendChild(img); + + img.onload = () => { + browser.test.log("image loaded"); + + let iframe = document.createElement("iframe"); + iframe.src = "about:blank?1"; + + iframe.onload = () => { + browser.test.log("iframe loaded"); + setTimeout(() => { + browser.test.notifyPass("background sub-window test done"); + }, 0); + }; + document.body.appendChild(iframe); + }; + }, + }); + + let loadCount = 0; + extension.onMessage("background-script-load", () => { + loadCount++; + }); + + await extension.startup(); + + await extension.awaitFinish("background sub-window test done"); + + equal(loadCount, 1, "background script loaded only once"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js new file mode 100644 index 0000000000..013a68726c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js @@ -0,0 +1,99 @@ +"use strict"; + +add_task(async function test_background_reload_and_unload() { + let events = []; + { + let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("reload-background", msg); + location.reload(); + }); + browser.test.sendMessage("background-url", location.href); + }, + }); + + await extension.startup(); + let backgroundUrl = await extension.awaitMessage("background-url"); + + let contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after loading an extension" + ); + equal(contextEvents[0].eventType, "load"); + equal( + contextEvents[0].url, + backgroundUrl, + "The ExtensionContext should be the background page" + ); + + extension.sendMessage("reload-background"); + await extension.awaitMessage("background-url"); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 2, + "ExtensionContext state changes after reloading the background page" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext of background page" + ); + equal( + contextEvents[0].url, + backgroundUrl, + "ExtensionContext URL = background" + ); + equal( + contextEvents[1].eventType, + "load", + "Create new ExtensionContext for background page" + ); + equal( + contextEvents[1].url, + backgroundUrl, + "ExtensionContext URL = background" + ); + + await extension.unload(); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after unloading the extension" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext for background page after extension unloads" + ); + equal( + contextEvents[0].url, + backgroundUrl, + "ExtensionContext URL = background" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js new file mode 100644 index 0000000000..8ca76ea3c2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js @@ -0,0 +1,104 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_BACKGROUND_PAGE_LOAD_MS"; +const HISTOGRAM_KEYED = "WEBEXT_BACKGROUND_PAGE_LOAD_MS_BY_ADDONID"; + +add_task(async function test_telemetry() { + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + clearHistograms(); + + assertHistogramEmpty(HISTOGRAM); + assertKeyedHistogramEmpty(HISTOGRAM_KEYED); + + await extension1.startup(); + await extension1.awaitMessage("loaded"); + + const processSnapshot = snapshot => { + return snapshot.sum > 0; + }; + + const processKeyedSnapshot = snapshot => { + let res = {}; + for (let key of Object.keys(snapshot)) { + res[key] = snapshot[key].sum > 0; + } + return res; + }; + + assertHistogramSnapshot( + HISTOGRAM, + { processSnapshot, expectedValue: true }, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + }, + }, + `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}` + ); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = Services.telemetry.getKeyedHistogramById( + HISTOGRAM_KEYED + ); + let histogramSum = histogram.snapshot().sum; + let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum; + + await extension2.startup(); + await extension2.awaitMessage("loaded"); + + assertHistogramSnapshot( + HISTOGRAM, + { + processSnapshot: snapshot => snapshot.sum > histogramSum, + expectedValue: true, + }, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + [extension2.extension.id]: true, + }, + }, + `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}` + ); + + equal( + histogramKeyed.snapshot()[extension1.extension.id].sum, + histogramSumExt1, + `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}` + ); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js new file mode 100644 index 0000000000..fb2ca27482 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js @@ -0,0 +1,41 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindowProperties() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let expectedValues = { + screenX: 0, + screenY: 0, + outerWidth: 0, + outerHeight: 0, + }; + + for (let k in window) { + try { + if (k in expectedValues) { + browser.test.assertEq( + expectedValues[k], + window[k], + `should return the expected value for window property: ${k}` + ); + } else { + void window[k]; + } + } catch (e) { + browser.test.assertEq( + null, + e, + `unexpected exception accessing window property: ${k}` + ); + } + } + + browser.test.notifyPass("background.testWindowProperties.done"); + }, + }); + await extension.startup(); + await extension.awaitFinish("background.testWindowProperties.done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js new file mode 100644 index 0000000000..c066147268 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js @@ -0,0 +1,54 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* + * This test extension has a background script 'missing.js' that is missing + * from the XPI. Such an extension should install/uninstall cleanly without + * causing timeouts. + */ +add_task(async function testXPIMissingBackGroundScript() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["missing.js"], + }, + }, + }); + + await extension.startup(); + await extension.unload(); + ok(true, "load/unload completed without timing out"); +}); + +/* + * This test extension includes a page with a missing script. The + * extension should install/uninstall cleanly without causing hangs. + */ +add_task(async function testXPIMissingPageScript() { + async function pageScript() { + browser.test.sendMessage("pageReady"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + files: { + "page.html": `<html><head> + <script src="missing.js"></script> + <script src="page.js"></script> + </head></html>`, + "page.js": pageScript, + }, + }); + + await extension.startup(); + let url = await extension.awaitMessage("ready"); + let contentPage = await ExtensionTestUtils.loadContentPage(url); + await extension.awaitMessage("pageReady"); + await extension.unload(); + await contentPage.close(); + + ok(true, "load/unload completed without timing out"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js new file mode 100644 index 0000000000..ed4eb8a664 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js @@ -0,0 +1,454 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +// The test extension uses an insecure update url. +Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false); + +const SETTINGS_ID = "test_settings_staged_restart_webext@tests.mozilla.org"; + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_browser_settings() { + const PERM_DENY_ACTION = Services.perms.DENY_ACTION; + const PERM_UNKNOWN_ACTION = Services.perms.UNKNOWN_ACTION; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + "dom.popup_allowed_events": Preferences.get("dom.popup_allowed_events"), + "image.animation_mode": "none", + "permissions.default.desktop-notification": PERM_UNKNOWN_ACTION, + "ui.context_menus.after_mouseup": false, + "browser.tabs.closeTabByDblclick": false, + "browser.tabs.loadBookmarksInTabs": false, + "browser.search.openintab": false, + "browser.tabs.insertRelatedAfterCurrent": true, + "browser.tabs.insertAfterCurrent": false, + "browser.display.document_color_use": 1, + "browser.display.use_document_fonts": 1, + "browser.zoom.full": true, + "browser.zoom.siteSpecific": true, + }; + + async function background() { + let listeners = new Set([]); + browser.test.onMessage.addListener(async (msg, apiName, value) => { + let apiObj = browser.browserSettings[apiName]; + // Don't add more than one listner per apiName. We leave the + // listener to ensure we do not get more calls than we expect. + if (!listeners.has(apiName)) { + apiObj.onChange.addListener(details => { + browser.test.sendMessage("onChange", { + details, + setting: apiName, + }); + }); + listeners.add(apiName); + } + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + browser.test.sendMessage("settingData", await apiObj.get({})); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + browser.test.sendMessage("no-op set"); + } + }); + } + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + useAddonManager: "temporary", + }); + + await promiseStartupManager(); + await extension.startup(); + + async function testSetting(setting, value, expected, expectedValue = value) { + extension.sendMessage("set", setting, value); + let data = await extension.awaitMessage("settingData"); + let dataChange = await extension.awaitMessage("onChange"); + equal(setting, dataChange.setting, "onChange fired"); + equal( + data.value, + dataChange.details.value, + "onChange fired with correct value" + ); + deepEqual( + data.value, + expectedValue, + `The ${setting} setting has the expected value.` + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + `The ${setting} setting has the expected levelOfControl.` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + async function testNoOpSetting(setting, value, expected) { + extension.sendMessage("setNoOp", setting, value); + await extension.awaitMessage("no-op set"); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + await testSetting("cacheEnabled", false, { + "browser.cache.disk.enable": false, + "browser.cache.memory.enable": false, + }); + await testSetting("cacheEnabled", true, { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + }); + + await testSetting("allowPopupsForUserEvents", false, { + "dom.popup_allowed_events": "", + }); + await testSetting("allowPopupsForUserEvents", true, { + "dom.popup_allowed_events": PREFS["dom.popup_allowed_events"], + }); + + for (let value of ["normal", "none", "once"]) { + await testSetting("imageAnimationBehavior", value, { + "image.animation_mode": value, + }); + } + + await testSetting("webNotificationsDisabled", true, { + "permissions.default.desktop-notification": PERM_DENY_ACTION, + }); + await testSetting("webNotificationsDisabled", false, { + // This pref is not defaulted on Android. + "permissions.default.desktop-notification": + AppConstants.MOZ_BUILD_APP !== "browser" + ? undefined + : PERM_UNKNOWN_ACTION, + }); + + // This setting is a no-op on Android. + if (AppConstants.platform === "android") { + await testNoOpSetting("contextMenuShowEvent", "mouseup", { + "ui.context_menus.after_mouseup": false, + }); + } else { + await testSetting("contextMenuShowEvent", "mouseup", { + "ui.context_menus.after_mouseup": true, + }); + } + + // "mousedown" is also a no-op on Windows. + if (["android", "win"].includes(AppConstants.platform)) { + await testNoOpSetting("contextMenuShowEvent", "mousedown", { + "ui.context_menus.after_mouseup": AppConstants.platform === "win", + }); + } else { + await testSetting("contextMenuShowEvent", "mousedown", { + "ui.context_menus.after_mouseup": false, + }); + } + + if (AppConstants.platform !== "android") { + await testSetting("closeTabsByDoubleClick", true, { + "browser.tabs.closeTabByDblclick": true, + }); + await testSetting("closeTabsByDoubleClick", false, { + "browser.tabs.closeTabByDblclick": false, + }); + } + + await testSetting("ftpProtocolEnabled", false, { + "network.ftp.enabled": false, + }); + await testSetting("ftpProtocolEnabled", true, { + "network.ftp.enabled": true, + }); + + await testSetting("newTabPosition", "afterCurrent", { + "browser.tabs.insertRelatedAfterCurrent": false, + "browser.tabs.insertAfterCurrent": true, + }); + await testSetting("newTabPosition", "atEnd", { + "browser.tabs.insertRelatedAfterCurrent": false, + "browser.tabs.insertAfterCurrent": false, + }); + await testSetting("newTabPosition", "relatedAfterCurrent", { + "browser.tabs.insertRelatedAfterCurrent": true, + "browser.tabs.insertAfterCurrent": false, + }); + + await testSetting("openBookmarksInNewTabs", true, { + "browser.tabs.loadBookmarksInTabs": true, + }); + await testSetting("openBookmarksInNewTabs", false, { + "browser.tabs.loadBookmarksInTabs": false, + }); + + await testSetting("openSearchResultsInNewTabs", true, { + "browser.search.openintab": true, + }); + await testSetting("openSearchResultsInNewTabs", false, { + "browser.search.openintab": false, + }); + + await testSetting("openUrlbarResultsInNewTabs", true, { + "browser.urlbar.openintab": true, + }); + await testSetting("openUrlbarResultsInNewTabs", false, { + "browser.urlbar.openintab": false, + }); + + await testSetting("overrideDocumentColors", "high-contrast-only", { + "browser.display.document_color_use": 0, + }); + await testSetting("overrideDocumentColors", "never", { + "browser.display.document_color_use": 1, + }); + await testSetting("overrideDocumentColors", "always", { + "browser.display.document_color_use": 2, + }); + + await testSetting("useDocumentFonts", false, { + "browser.display.use_document_fonts": 0, + }); + await testSetting("useDocumentFonts", true, { + "browser.display.use_document_fonts": 1, + }); + + await testSetting("zoomFullPage", true, { + "browser.zoom.full": true, + }); + await testSetting("zoomFullPage", false, { + "browser.zoom.full": false, + }); + + await testSetting("zoomSiteSpecific", true, { + "browser.zoom.siteSpecific": true, + }); + await testSetting("zoomSiteSpecific", false, { + "browser.zoom.siteSpecific": false, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_bad_value() { + async function background() { + await browser.test.assertRejects( + browser.browserSettings.contextMenuShowEvent.set({ value: "bad" }), + /bad is not a valid value for contextMenuShowEvent/, + "contextMenuShowEvent.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideDocumentColors.set({ value: 2 }), + /2 is not a valid value for overrideDocumentColors/, + "overrideDocumentColors.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideDocumentColors.set({ value: "bad" }), + /bad is not a valid value for overrideDocumentColors/, + "overrideDocumentColors.set rejects with an invalid value." + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_bad_value_android() { + if (AppConstants.platform !== "android") { + return; + } + + async function background() { + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.set({ value: true }), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.set rejects on Android." + ); + + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.get({}), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.get rejects on Android." + ); + + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.clear({}), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.clear rejects on Android." + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Verifies settings remain after a staged update on restart. +add_task(async function delay_updates_settings_after_restart() { + let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + "test_settings_staged_restart_webext@tests.mozilla.org": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart_v2.xpi", + }, + ], + }, + }, + }); + const update_xpi = AddonTestUtils.createTempXPIFile({ + "manifest.json": { + manifest_version: 2, + name: "Delay Upgrade", + version: "2.0", + applications: { + gecko: { id: SETTINGS_ID }, + }, + permissions: ["browserSettings"], + }, + }); + server.registerFile( + `/addons/test_settings_staged_restart_v2.xpi`, + update_xpi + ); + + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: SETTINGS_ID, + update_url: `http://example.com/test_update.json`, + }, + }, + permissions: ["browserSettings"], + }, + background() { + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details) { + await browser.browserSettings.webNotificationsDisabled.set({ + value: true, + }); + if (details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.notifyPass("delay"); + } + } else { + browser.test.fail("no details object passed"); + } + }); + browser.test.sendMessage("ready"); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + let prefname = "permissions.default.desktop-notification"; + let val = Services.prefs.getIntPref(prefname); + Assert.notEqual(val, 2, "webNotificationsDisabled pref not set"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal(install.state, AddonManager.STATE_POSTPONED); + await extension.awaitFinish("delay"); + + // restarting allows upgrade to proceed + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + + // If an update is not handled correctly we would fail here. Bug 1639705. + val = Services.prefs.getIntPref(prefname); + Assert.equal(val, 2, "webNotificationsDisabled pref set"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + + val = Services.prefs.getIntPref(prefname); + Assert.notEqual(val, 2, "webNotificationsDisabled pref not set"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js new file mode 100644 index 0000000000..8d1d16c743 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js @@ -0,0 +1,36 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_homepage_get_without_set() { + async function background() { + let homepage = await browser.browserSettings.homepageOverride.get({}); + browser.test.sendMessage("homepage", homepage); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + let defaultHomepage = Services.prefs.getStringPref( + "browser.startup.homepage" + ); + + await extension.startup(); + let homepage = await extension.awaitMessage("homepage"); + equal( + homepage.value, + defaultHomepage, + "The homepageOverride setting has the expected value." + ); + equal( + homepage.levelOfControl, + "not_controllable", + "The homepageOverride setting has the expected levelOfControl." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js new file mode 100644 index 0000000000..1df5e60478 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testInvalidArguments() { + async function background() { + const UNSUPPORTED_DATA_TYPES = ["appcache", "fileSystems", "webSQL"]; + + await browser.test.assertRejects( + browser.browsingData.remove( + { originTypes: { protectedWeb: true } }, + { cookies: true } + ), + "Firefox does not support protectedWeb or extension as originTypes.", + "Expected error received when using protectedWeb originType." + ); + + await browser.test.assertRejects( + browser.browsingData.removeCookies({ originTypes: { extension: true } }), + "Firefox does not support protectedWeb or extension as originTypes.", + "Expected error received when using extension originType." + ); + + for (let dataType of UNSUPPORTED_DATA_TYPES) { + let dataTypes = {}; + dataTypes[dataType] = true; + browser.test.assertThrows( + () => browser.browsingData.remove({}, dataTypes), + /Type error for parameter dataToRemove/, + `Expected error received when using ${dataType} dataType.` + ); + } + + browser.test.notifyPass("invalidArguments"); + } + + let extensionData = { + background: background, + manifest: { + permissions: ["browsingData"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("invalidArguments"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js new file mode 100644 index 0000000000..612f2dd0f3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js @@ -0,0 +1,456 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const { SiteDataTestUtils } = ChromeUtils.import( + "resource://testing-common/SiteDataTestUtils.jsm" +); + +const COOKIE = { + host: "example.com", + name: "test_cookie", + path: "/", +}; +const COOKIE_NET = { + host: "example.net", + name: "test_cookie", + path: "/", +}; +const COOKIE_ORG = { + host: "example.org", + name: "test_cookie", + path: "/", +}; +let since, oldCookie; + +function addCookie(cookie) { + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + "test", + false, + false, + false, + Date.now() / 1000 + 10000, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + ok( + Services.cookies.cookieExists(cookie.host, cookie.path, cookie.name, {}), + `Cookie ${cookie.name} was created.` + ); +} + +async function setUpCookies() { + Services.cookies.removeAll(); + + // Add a cookie which will end up with an older creationTime. + oldCookie = Object.assign({}, COOKIE, { name: Date.now() }); + addCookie(oldCookie); + await new Promise(resolve => setTimeout(resolve, 10)); + since = Date.now(); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Add a cookie which will end up with a more recent creationTime. + addCookie(COOKIE); + + // Add cookies for different domains. + addCookie(COOKIE_NET); + addCookie(COOKIE_ORG); +} + +async function setUpCache() { + Services.cache2.clear(); + + // Add cache entries for different domains. + for (const domain of ["example.net", "example.org", "example.com"]) { + await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "disk"); + await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "memory"); + } +} + +function hasCacheEntry(domain) { + const disk = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "disk"); + const memory = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "memory"); + + equal( + disk, + memory, + `For ${domain} either either both or neither caches need to exists.` + ); + return disk; +} + +add_task(async function testCache() { + function background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "removeCache") { + await browser.browsingData.removeCache({}); + } else { + await browser.browsingData.remove({}, { cache: true }); + } + browser.test.sendMessage("cacheRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + await setUpCache(); + + extension.sendMessage(method); + await extension.awaitMessage("cacheRemoved"); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + } + + await extension.startup(); + + await testRemovalMethod("removeCache"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); + +add_task(async function testCookies() { + // Above in setUpCookies we create an 'old' cookies, wait 10ms, then log a timestamp. + // Here we ask the browser to delete all cookies after the timestamp, with the intention + // that the 'old' cookie is not removed. The issue arises when the timer precision is + // low enough such that the timestamp that gets logged is the same as the 'old' cookie. + // We hardcode a precision value to ensure that there is time between the 'old' cookie + // and the timestamp generation. + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", true); + Services.prefs.setIntPref( + "privacy.resistFingerprinting.reduceTimerPrecision.microseconds", + 2000 + ); + + registerCleanupFunction(function() { + Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); + Services.prefs.clearUserPref( + "privacy.resistFingerprinting.reduceTimerPrecision.microseconds" + ); + }); + + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeCookies") { + await browser.browsingData.removeCookies(options); + } else { + await browser.browsingData.remove(options, { cookies: true }); + } + browser.test.sendMessage("cookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear cookies with a recent since value. + await setUpCookies(); + extension.sendMessage(method, { since }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cookies with an old since value. + await setUpCookies(); + addCookie(COOKIE); + extension.sendMessage(method, { since: since - 100000 }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cookies with no since value and valid originTypes. + await setUpCookies(); + extension.sendMessage(method, { + originTypes: { unprotectedWeb: true, protectedWeb: false }, + }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + `Cookie ${oldCookie.name} was removed.` + ); + } + + await extension.startup(); + + await testRemovalMethod("removeCookies"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); + +add_task(async function testCacheAndCookies() { + function background() { + browser.test.onMessage.addListener(async options => { + await browser.browsingData.remove(options, { + cache: true, + cookies: true, + }); + browser.test.sendMessage("cacheAndCookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + + // Clear cache and cookies with a recent since value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ since }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Cache does not support |since| and deletes everything! + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + // Clear cache and cookies with an old since value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ since: since - 100000 }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + // Cache does not support |since| and deletes everything! + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cache and cookies with hostnames value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ + hostnames: ["example.net", "example.org", "unknown.com"], + }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was not removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was removed.` + ); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(hasCacheEntry("example.com"), "example.com cache was not removed"); + + // Clear cache and cookies with (empty) hostnames value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ hostnames: [] }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was not removed.` + ); + ok( + Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was not removed.` + ); + ok( + Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was not removed.` + ); + + ok(hasCacheEntry("example.net"), "example.net cache was not removed"); + ok(hasCacheEntry("example.org"), "example.org cache was not removed"); + ok(hasCacheEntry("example.com"), "example.com cache was not removed"); + + // Clear cache and cookies with both hostnames and since values. + await setUpCache(); + await setUpCookies(); + extension.sendMessage({ hostnames: ["example.com"], since }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + ok( + Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + "Cookie with different hostname was not removed" + ); + ok( + Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + "Cookie with different hostname was not removed" + ); + + ok(hasCacheEntry("example.net"), "example.net cache was not removed"); + ok(hasCacheEntry("example.org"), "example.org cache was not removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + // Clear cache and cookies with no since or hostnames value. + await setUpCache(); + await setUpCookies(); + extension.sendMessage({}); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + `Cookie ${oldCookie.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was removed.` + ); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js new file mode 100644 index 0000000000..d3d066efd2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js @@ -0,0 +1,192 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +// "Normal" cookie +const COOKIE_NORMAL = { + host: "example.com", + name: "test_cookie", + path: "/", + originAttributes: {}, +}; +// Private browsing cookie +const COOKIE_PRIVATE = { + host: "example.net", + name: "test_cookie", + path: "/", + originAttributes: { + privateBrowsingId: 1, + }, +}; +// "firefox-container-1" cookie +const COOKIE_CONTAINER = { + host: "example.org", + name: "test_cookie", + path: "/", + originAttributes: { + userContextId: 1, + }, +}; + +function cookieExists(cookie) { + return Services.cookies.cookieExists( + cookie.host, + cookie.path, + cookie.name, + cookie.originAttributes + ); +} + +function addCookie(cookie) { + const THE_FUTURE = Date.now() + 5 * 60; + + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + "test", + false, + false, + false, + THE_FUTURE, + cookie.originAttributes, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + + ok(cookieExists(cookie), `Cookie ${cookie.name} was created.`); +} + +async function setUpCookies() { + Services.cookies.removeAll(); + + addCookie(COOKIE_NORMAL); + addCookie(COOKIE_PRIVATE); + addCookie(COOKIE_CONTAINER); +} + +add_task(async function testCookies() { + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeCookies") { + await browser.browsingData.removeCookies(options); + } else { + await browser.browsingData.remove(options, { cookies: true }); + } + browser.test.sendMessage("cookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear only "normal"/default cookies. + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-default" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(!cookieExists(COOKIE_NORMAL), "Normal cookie was removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear container cookie + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-container-1" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed"); + + // Clear private cookie + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-private" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear container cookie with correct hostname + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-container-1", + hostnames: ["example.org"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed"); + + // Clear container cookie with incorrect hostname; nothing is removed + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-container-1", + hostnames: ["example.com"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie with correct hostname + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-private", + hostnames: ["example.net"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie with incorrect hostname; nothing is removed + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-private", + hostnames: ["example.com"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie by hostname + await setUpCookies(); + + extension.sendMessage(method, { + hostnames: ["example.net"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + } + + await extension.startup(); + + await testRemovalMethod("removeCookies"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js new file mode 100644 index 0000000000..45c6a122fd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js @@ -0,0 +1,109 @@ +"use strict"; + +/** + * This duplicates the test from netwerk/test/unit/test_captive_portal_service.js + * however using an extension to gather the captive portal information. + */ + +const PREF_CAPTIVE_ENABLED = "network.captive-portal-service.enabled"; +const PREF_CAPTIVE_TESTMODE = "network.captive-portal-service.testMode"; +const PREF_CAPTIVE_MINTIME = "network.captive-portal-service.minInterval"; +const PREF_CAPTIVE_ENDPOINT = "captivedetect.canonicalURL"; +const PREF_DNS_NATIVE_IS_LOCALHOST = "network.dns.native-is-localhost"; + +const SUCCESS_STRING = "success\n"; +let cpResponse = SUCCESS_STRING; + +const httpserver = createHttpServer(); +httpserver.registerPathHandler("/captive.txt", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain"); + response.write(cpResponse); +}); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_CAPTIVE_ENABLED); + Services.prefs.clearUserPref(PREF_CAPTIVE_TESTMODE); + Services.prefs.clearUserPref(PREF_CAPTIVE_ENDPOINT); + Services.prefs.clearUserPref(PREF_CAPTIVE_MINTIME); + Services.prefs.clearUserPref(PREF_DNS_NATIVE_IS_LOCALHOST); +}); + +add_task(function setup() { + Services.prefs.setCharPref( + PREF_CAPTIVE_ENDPOINT, + `http://localhost:${httpserver.identity.primaryPort}/captive.txt` + ); + Services.prefs.setBoolPref(PREF_CAPTIVE_TESTMODE, true); + Services.prefs.setIntPref(PREF_CAPTIVE_MINTIME, 0); + Services.prefs.setBoolPref(PREF_DNS_NATIVE_IS_LOCALHOST, true); +}); + +add_task(async function test_captivePortal_basic() { + let cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService( + Ci.nsICaptivePortalService + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["captivePortal"], + }, + isPrivileged: true, + async background() { + browser.captivePortal.onConnectivityAvailable.addListener(details => { + browser.test.log( + `onConnectivityAvailable received ${JSON.stringify(details)}` + ); + browser.test.sendMessage("connectivity", details); + }); + + browser.captivePortal.onStateChanged.addListener(details => { + browser.test.log(`onStateChanged received ${JSON.stringify(details)}`); + browser.test.sendMessage("state", details); + }); + + browser.test.onMessage.addListener(async msg => { + if (msg == "getstate") { + browser.test.sendMessage( + "getstate", + await browser.captivePortal.getState() + ); + } + }); + browser.test.assertEq( + "unknown", + await browser.captivePortal.getState(), + "initial state unknown" + ); + }, + }); + await extension.startup(); + + // The captive portal service is started by nsIOService when the pref becomes true, so we + // toggle the pref. We cannot set to false before the extension loads above. + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, true); + + let details = await extension.awaitMessage("connectivity"); + equal(details.status, "clear", "initial connectivity"); + extension.sendMessage("getstate"); + details = await extension.awaitMessage("getstate"); + equal(details, "not_captive", "initial state"); + + info("REFRESH to other"); + cpResponse = "other"; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("state"); + equal(details.state, "locked_portal", "state in portal"); + + info("REFRESH to success"); + cpResponse = SUCCESS_STRING; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("connectivity"); + equal(details.status, "captive", "final connectivity"); + + details = await extension.awaitMessage("state"); + equal(details.state, "unlocked_portal", "state after unlocking portal"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js new file mode 100644 index 0000000000..7bd83b0572 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_url_get_without_set() { + async function background() { + browser.captivePortal.canonicalURL.onChange.addListener(details => { + browser.test.sendMessage("url", details); + }); + let url = await browser.captivePortal.canonicalURL.get({}); + browser.test.sendMessage("url", url); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["captivePortal"], + }, + }); + + let defaultURL = Services.prefs.getStringPref("captivedetect.canonicalURL"); + + await extension.startup(); + let url = await extension.awaitMessage("url"); + equal( + url.value, + defaultURL, + "The canonicalURL setting has the expected value." + ); + equal( + url.levelOfControl, + "not_controllable", + "The canonicalURL setting has the expected levelOfControl." + ); + + Services.prefs.setStringPref( + "captivedetect.canonicalURL", + "http://example.com" + ); + url = await extension.awaitMessage("url"); + equal( + url.value, + "http://example.com", + "The canonicalURL setting has the expected value." + ); + equal( + url.levelOfControl, + "not_controllable", + "The canonicalURL setting has the expected levelOfControl." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js new file mode 100644 index 0000000000..71174716fd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js @@ -0,0 +1,591 @@ +"use strict"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function check_applied_styles() { + const urlElStyle = getComputedStyle( + document.querySelector("#registered-extension-url-style") + ); + const blobElStyle = getComputedStyle( + document.querySelector("#registered-extension-text-style") + ); + + browser.test.sendMessage("registered-styles-results", { + registeredExtensionUrlStyleBG: urlElStyle["background-color"], + registeredExtensionBlobStyleBG: blobElStyle["background-color"], + }); +} + +add_task(async function test_contentscripts_register_css() { + async function background() { + let cssCode = ` + #registered-extension-text-style { + background-color: blue; + } + `; + + const matches = ["http://localhost/*/file_sample_registered_styles.html"]; + + browser.test.assertThrows( + () => { + browser.contentScripts.register({ + matches, + unknownParam: "unexpected property", + }); + }, + /Unexpected property "unknownParam"/, + "contentScripts.register throws on unexpected properties" + ); + + let fileScript = await browser.contentScripts.register({ + css: [{ file: "registered_ext_style.css" }], + matches, + runAt: "document_start", + }); + + let textScript = await browser.contentScripts.register({ + css: [{ code: cssCode }], + matches, + runAt: "document_start", + }); + + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "unregister-text": + await textScript.unregister().catch(err => { + browser.test.fail( + `Unexpected exception while unregistering text style: ${err}` + ); + }); + + await browser.test.assertRejects( + textScript.unregister(), + /Content script already unregistered/, + "Got the expected rejection on calling script.unregister() multiple times" + ); + + browser.test.sendMessage("unregister-text:done"); + break; + case "unregister-file": + await fileScript.unregister().catch(err => { + browser.test.fail( + `Unexpected exception while unregistering url style: ${err}` + ); + }); + + await browser.test.assertRejects( + fileScript.unregister(), + /Content script already unregistered/, + "Got the expected rejection on calling script.unregister() multiple times" + ); + + browser.test.sendMessage("unregister-file:done"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("background_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "http://localhost/*/file_sample_registered_styles.html", + "<all_urls>", + ], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + + files: { + "check_applied_styles.js": check_applied_styles, + "registered_ext_style.css": ` + #registered-extension-url-style { + background-color: red; + } + `, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background_ready"); + + // Ensure that a content page running in a content process and which has been + // started after the content scripts has been registered, it still receives + // and registers the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionUrlStyleBG, + "rgb(255, 0, 0)", + "The expected style has been applied from the registered extension url style" + ); + equal( + registeredStylesResults.registeredExtensionBlobStyleBG, + "rgb(0, 0, 255)", + "The expected style has been applied from the registered extension blob style" + ); + + extension.sendMessage("unregister-file"); + await extension.awaitMessage("unregister-file:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredURLStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredURLStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + equal( + unregisteredURLStylesResults.registeredExtensionBlobStyleBG, + "rgb(0, 0, 255)", + "The expected style has been applied from the registered extension blob style" + ); + + extension.sendMessage("unregister-text"); + await extension.awaitMessage("unregister-text:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredBlobStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredBlobStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + equal( + unregisteredBlobStylesResults.registeredExtensionBlobStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension blob style has been unregistered" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_contentscripts_unregister_on_context_unload() { + async function background() { + const frame = document.createElement("iframe"); + frame.setAttribute("src", "/background-frame.html"); + + document.body.appendChild(frame); + + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "unload-frame": + frame.remove(); + browser.test.sendMessage("unload-frame:done"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("background_ready"); + } + + async function background_frame() { + await browser.contentScripts.register({ + css: [{ file: "registered_ext_style.css" }], + matches: ["http://localhost/*/file_sample_registered_styles.html"], + runAt: "document_start", + }); + + browser.test.sendMessage("background_frame_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample_registered_styles.html"], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + + files: { + "background-frame.html": `<!DOCTYPE html> + <html> + <head> + <script src="background-frame.js"></script> + </head> + <body> + </body> + </html> + `, + "background-frame.js": background_frame, + "check_applied_styles.js": check_applied_styles, + "registered_ext_style.css": ` + #registered-extension-url-style { + background-color: red; + } + `, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background_ready"); + + // Wait the background frame to have been loaded and its script + // executed. + await extension.awaitMessage("background_frame_ready"); + + // Ensure that a content page running in a content process and which has been + // started after the content scripts has been registered, it still receives + // and registers the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionUrlStyleBG, + "rgb(255, 0, 0)", + "The expected style has been applied from the registered extension url style" + ); + + extension.sendMessage("unload-frame"); + await extension.awaitMessage("unload-frame:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredURLStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredURLStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_contentscripts_register_js() { + async function background() { + browser.runtime.onMessage.addListener( + ([msg, expectedStates, readyState], sender) => { + if (msg == "chrome-namespace-ok") { + browser.test.sendMessage(msg); + return; + } + + browser.test.assertEq("script-run", msg, "message type is correct"); + browser.test.assertTrue( + expectedStates.includes(readyState), + `readyState "${readyState}" is one of [${expectedStates}]` + ); + browser.test.sendMessage("script-run-" + expectedStates[0]); + } + ); + + // Raise an exception when the content script cannot be registered + // because the extension has no permission to access the specified origin. + + await browser.test.assertRejects( + browser.contentScripts.register({ + matches: ["http://*/*"], + js: [ + { + code: + 'browser.test.fail("content script with wrong matches should not run")', + }, + ], + }), + /Permission denied to register a content script for/, + "The reject contains the expected error message" + ); + + // Register a content script from a JS code string. + + function textScriptCodeStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function textScriptCodeEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function textScriptCodeIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + // Register content scripts from both extension URLs and plain JS code strings. + + const content_scripts = [ + // Plain JS code strings. + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeStart})()` }], + runAt: "document_start", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeEnd})()` }], + runAt: "document_end", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeIdle})()` }], + runAt: "document_idle", + }, + // Extension URLs. + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_start.js" }], + runAt: "document_start", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_end.js" }], + runAt: "document_end", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_idle.js" }], + runAt: "document_idle", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script.js" }], + // "runAt" is not specified here to ensure that it defaults to document_idle when missing. + }, + ]; + + const expectedAPIs = ["unregister"]; + + for (const scriptOptions of content_scripts) { + const script = await browser.contentScripts.register(scriptOptions); + const actualAPIs = Object.keys(script); + + browser.test.assertEq( + JSON.stringify(expectedAPIs), + JSON.stringify(actualAPIs), + `Got a script API object for ${scriptOptions.js[0]}` + ); + } + + browser.test.sendMessage("background-ready"); + } + + function contentScriptStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function contentScriptEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function contentScriptIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + function contentScript() { + let manifest = browser.runtime.getManifest(); + void manifest.permissions; + browser.runtime.sendMessage(["chrome-namespace-ok"]); + } + + let extensionData = { + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + }, + background, + + files: { + "content_script_start.js": contentScriptStart, + "content_script_end.js": contentScriptEnd, + "content_script_idle.js": contentScriptIdle, + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let loadingCount = 0; + let interactiveCount = 0; + let completeCount = 0; + extension.onMessage("script-run-loading", () => { + loadingCount++; + }); + extension.onMessage("script-run-interactive", () => { + interactiveCount++; + }); + + let completePromise = new Promise(resolve => { + extension.onMessage("script-run-complete", () => { + completeCount++; + resolve(); + }); + }); + + let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok"); + + // Ensure that a content page running in a content process and which has been + // already loaded when the content scripts has been registered, it has received + // and registered the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await contentPage.loadURL(`${BASE_URL}/file_sample.html`); + + await Promise.all([completePromise, chromeNamespacePromise]); + + await contentPage.close(); + + // Expect two content scripts to run (one registered using an extension URL + // and one registered from plain JS code). + equal(loadingCount, 2, "document_start script ran exactly twice"); + equal(interactiveCount, 2, "document_end script ran exactly twice"); + equal(completeCount, 2, "document_idle script ran exactly twice"); + + await extension.unload(); +}); + +// Test that the contentScript.register options are correctly translated +// into the expected WebExtensionContentScript properties. +add_task(async function test_contentscripts_register_all_options() { + async function background() { + await browser.contentScripts.register({ + js: [{ file: "content_script.js" }], + css: [{ file: "content_style.css" }], + matches: ["http://localhost/*"], + excludeMatches: ["http://localhost/exclude/*"], + excludeGlobs: ["*_exclude.html"], + includeGlobs: ["*_include.html"], + allFrames: true, + matchAboutBlank: true, + runAt: "document_start", + }); + + browser.test.sendMessage("background-ready", window.location.origin); + } + + const extensionData = { + manifest: { + permissions: ["http://localhost/*"], + }, + background, + + files: { + "content_script.js": "", + "content_style.css": "", + }, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + const baseExtURL = await extension.awaitMessage("background-ready"); + + const policy = WebExtensionPolicy.getByID(extension.id); + + ok(policy, "Got the WebExtensionPolicy for the test extension"); + equal( + policy.contentScripts.length, + 1, + "Got the expected number of registered content scripts" + ); + + const script = policy.contentScripts[0]; + let { allFrames, cssPaths, jsPaths, matchAboutBlank, runAt } = script; + + deepEqual( + { + allFrames, + cssPaths, + jsPaths, + matchAboutBlank, + runAt, + }, + { + allFrames: true, + cssPaths: [`${baseExtURL}/content_style.css`], + jsPaths: [`${baseExtURL}/content_script.js`], + matchAboutBlank: true, + runAt: "document_start", + }, + "Got the expected content script properties" + ); + + ok( + script.matchesURI(Services.io.newURI("http://localhost/ok_include.html")), + "matched and include globs should match" + ); + ok( + !script.matchesURI( + Services.io.newURI("http://localhost/exclude/ok_include.html") + ), + "exclude matches should not match" + ); + ok( + !script.matchesURI(Services.io.newURI("http://localhost/ok_exclude.html")), + "exclude globs should not match" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js new file mode 100644 index 0000000000..47de723f0f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js @@ -0,0 +1,251 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +server.registerPathHandler("/worker.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/javascript", false); + response.write("let x = true;"); +}); + +const baseCSP = []; +baseCSP[2] = { + "object-src": ["blob:", "filesystem:", "moz-extension:", "'self'"], + "script-src": [ + "'unsafe-eval'", + "'unsafe-inline'", + "blob:", + "filesystem:", + "http://localhost:*", + "http://127.0.0.1:*", + "https://*", + "moz-extension:", + "'self'", + ], +}; +baseCSP[3] = { + "object-src": ["'self'"], + "script-src": ["http://localhost:*", "http://127.0.0.1:*", "'self'"], + "worker-src": ["http://localhost:*", "http://127.0.0.1:*", "'self'"], +}; + +/** + * Tests that content security policies for an add-on are actually applied to * + * documents that belong to it. This tests both the base policies and add-on + * specific policies, and ensures that the parsed policies applied to the + * document's principal match what was specified in the policy string. + * + * @param {number} [manifest_version] + * @param {object} [customCSP] + */ +async function testPolicy(manifest_version = 2, customCSP = null) { + let baseURL; + + let addonCSP = { + "object-src": ["'self'"], + "script-src": ["'self'"], + }; + + let content_security_policy = null; + + if (customCSP) { + for (let key of Object.keys(customCSP)) { + addonCSP[key] = customCSP[key].split(/\s+/); + } + + content_security_policy = Object.keys(customCSP) + .map(key => `${key} ${customCSP[key]}`) + .join("; "); + } + + function checkSource(name, policy, expected) { + // fallback to script-src when comparing worker-src if policy does not include worker-src + let policySrc = + name != "worker-src" || policy[name] + ? policy[name] + : policy["script-src"]; + equal( + JSON.stringify(policySrc.sort()), + JSON.stringify(expected[name].sort()), + `Expected value for ${name}` + ); + } + + function checkCSP(csp, location) { + let policies = csp["csp-policies"]; + + info(`Base policy for ${location}`); + let base = baseCSP[manifest_version]; + + equal(policies[0]["report-only"], false, "Policy is not report-only"); + for (let key in base) { + checkSource(key, policies[0], base); + } + + info(`Add-on policy for ${location}`); + + equal(policies[1]["report-only"], false, "Policy is not report-only"); + for (let key in addonCSP) { + checkSource(key, policies[1], addonCSP); + } + } + + function background() { + browser.test.sendMessage( + "base-url", + browser.extension.getURL("").replace(/\/$/, "") + ); + + browser.test.sendMessage("background-csp", window.getCSP()); + } + + function tabScript() { + browser.test.sendMessage("tab-csp", window.getCSP()); + + const worker = new Worker("worker.js"); + worker.onmessage = event => { + browser.test.sendMessage("worker-csp", event.data); + }; + + worker.postMessage({}); + } + + function testWorker(port) { + this.onmessage = () => { + try { + // eslint-disable-next-line no-undef + importScripts(`http://127.0.0.1:${port}/worker.js`); + postMessage({ loaded: true }); + } catch (e) { + postMessage({ loaded: false }); + } + }; + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + + files: { + "tab.html": `<html><head><meta charset="utf-8"> + <script src="tab.js"></${"script"}></head></html>`, + + "tab.js": tabScript, + + "content.html": `<html><head><meta charset="utf-8"></head></html>`, + "worker.js": `(${testWorker})(${server.identity.primaryPort})`, + }, + + manifest: { + manifest_version, + content_security_policy, + + web_accessible_resources: ["content.html", "tab.html"], + }, + }); + + function frameScript() { + // eslint-disable-next-line mozilla/balanced-listeners + addEventListener( + "DOMWindowCreated", + event => { + let win = event.target.ownerGlobal; + function getCSP() { + let { cspJSON } = win.document; + return win.wrappedJSObject.JSON.parse(cspJSON); + } + Cu.exportFunction(getCSP, win, { defineAs: "getCSP" }); + }, + true + ); + } + let frameScriptURL = `data:,(${encodeURI(frameScript)}).call(this)`; + Services.mm.loadFrameScript(frameScriptURL, true, true); + + info(`Testing CSP for policy: ${content_security_policy}`); + + await extension.startup(); + + baseURL = await extension.awaitMessage("base-url"); + + let tabPage = await ExtensionTestUtils.loadContentPage( + `${baseURL}/tab.html`, + { extension } + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + let contentCSP = await contentPage.spawn( + `${baseURL}/content.html`, + async src => { + let doc = this.content.document; + + let frame = doc.createElement("iframe"); + frame.src = src; + doc.body.appendChild(frame); + + await new Promise(resolve => { + frame.onload = resolve; + }); + + return frame.contentWindow.wrappedJSObject.getCSP(); + } + ); + + let backgroundCSP = await extension.awaitMessage("background-csp"); + checkCSP(backgroundCSP, "background page"); + + let tabCSP = await extension.awaitMessage("tab-csp"); + checkCSP(tabCSP, "tab page"); + + checkCSP(contentCSP, "content frame"); + + let workerCSP = await extension.awaitMessage("worker-csp"); + // TODO BUG 1685627: This test should fail if localhost is not in the csp. + ok(workerCSP.loaded, "worker loaded"); + + await contentPage.close(); + await tabPage.close(); + + await extension.unload(); + + Services.mm.removeDelayedFrameScript(frameScriptURL); +} + +add_task(async function testCSP() { + await testPolicy(2, null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + await testPolicy(2, { + "object-src": "'self' https://*.example.com", + "script-src": `'self' https://*.example.com 'unsafe-eval' ${hash}`, + }); + + await testPolicy(2, { + "object-src": "'none'", + "script-src": `'self'`, + }); + + await testPolicy(3, { + "object-src": "'self' http://localhost", + "script-src": `'self' http://localhost:123 ${hash}`, + "worker-src": `'self' http://127.0.0.1:*`, + }); + + await testPolicy(3, { + "object-src": "'none'", + "script-src": `'self'`, + "worker-src": `'self'`, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js new file mode 100644 index 0000000000..1d130798f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js @@ -0,0 +1,266 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_contentscript_runAt() { + function background() { + browser.runtime.onMessage.addListener( + ([msg, expectedStates, readyState], sender) => { + if (msg == "chrome-namespace-ok") { + browser.test.sendMessage(msg); + return; + } + + browser.test.assertEq("script-run", msg, "message type is correct"); + browser.test.assertTrue( + expectedStates.includes(readyState), + `readyState "${readyState}" is one of [${expectedStates}]` + ); + browser.test.sendMessage("script-run-" + expectedStates[0]); + } + ); + } + + function contentScriptStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function contentScriptEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function contentScriptIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + function contentScript() { + let manifest = browser.runtime.getManifest(); + void manifest.applications.gecko.id; + browser.runtime.sendMessage(["chrome-namespace-ok"]); + } + + let extensionData = { + manifest: { + applications: { gecko: { id: "contentscript@tests.mozilla.org" } }, + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_start.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_end.js"], + run_at: "document_end", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_idle.js"], + run_at: "document_idle", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_idle.js"], + // Test default `run_at`. + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + background, + + files: { + "content_script_start.js": contentScriptStart, + "content_script_end.js": contentScriptEnd, + "content_script_idle.js": contentScriptIdle, + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let loadingCount = 0; + let interactiveCount = 0; + let completeCount = 0; + extension.onMessage("script-run-loading", () => { + loadingCount++; + }); + extension.onMessage("script-run-interactive", () => { + interactiveCount++; + }); + + let completePromise = new Promise(resolve => { + extension.onMessage("script-run-complete", () => { + completeCount++; + if (completeCount > 1) { + resolve(); + } + }); + }); + + let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok"); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await Promise.all([completePromise, chromeNamespacePromise]); + + await contentPage.close(); + + equal(loadingCount, 1, "document_start script ran exactly once"); + equal(interactiveCount, 1, "document_end script ran exactly once"); + equal(completeCount, 2, "document_idle script ran exactly twice"); + + await extension.unload(); +}); + +add_task(async function test_contentscript_window_open() { + if (AppConstants.DEBUG && ExtensionTestUtils.remoteContentScripts) { + return; + } + + let script = async () => { + /* globals x */ + browser.test.assertEq(1, x, "Should only run once"); + + if (top !== window) { + // Wait for our parent page to load, then set a timeout to wait for the + // document.open call, so we make sure to not tear down the extension + // until after we've done the document.open. + await new Promise(resolve => { + top.addEventListener("load", () => setTimeout(resolve, 0), { + once: true, + }); + }); + } + + browser.test.sendMessage("content-script", [location.href, top === window]); + }; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "contentscript@tests.mozilla.org" } }, + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["content_script.js"], + run_at: "document_start", + match_about_blank: true, + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": ` + var x = (x || 0) + 1; + (${script})(); + `, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_document_open.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let [pageURL, pageIsTop] = await extension.awaitMessage("content-script"); + + // Sometimes we get a content script load for the initial about:blank + // top level frame here, sometimes we don't. Either way is fine, as long as we + // don't get two loads into the same document.open() document. + if (pageURL === "about:blank") { + equal(pageIsTop, true); + [pageURL, pageIsTop] = await extension.awaitMessage("content-script"); + } + + Assert.deepEqual([pageURL, pageIsTop], [url, true]); + + let [frameURL, isTop] = await extension.awaitMessage("content-script"); + Assert.deepEqual([frameURL, isTop], [url, false]); + + await contentPage.close(); + await extension.unload(); +}); + +// This test verify that a cached script is still able to catch the document +// while it is still loading (when we do not block the document parsing as +// we do for a non cached script). +add_task(async function test_cached_contentscript_on_document_start() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_document_open.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js": ` + browser.test.sendMessage("content-script-loaded", { + url: window.location.href, + documentReadyState: document.readyState, + }); + `, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_document_open.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let msg = await extension.awaitMessage("content-script-loaded"); + Assert.deepEqual( + msg, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a non cached script" + ); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(url); + + let msgFromCached = await extension.awaitMessage("content-script-loaded"); + Assert.deepEqual( + msgFromCached, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a cached script" + ); + + await extension.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js new file mode 100644 index 0000000000..023cc3d2a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js @@ -0,0 +1,78 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/blank-iframe.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<iframe></iframe>"); +}); + +add_task(async function content_script_at_document_start() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["start.js"], + run_at: "document_start", + match_about_blank: true, + }, + ], + }, + + files: { + "start.js": function() { + browser.test.sendMessage("content-script-done"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + await extension.awaitMessage("content-script-done"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function content_style_at_document_start() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + css: ["start.css"], + run_at: "document_start", + match_about_blank: true, + }, + { + matches: ["<all_urls>"], + js: ["end.js"], + run_at: "document_end", + match_about_blank: true, + }, + ], + }, + + files: { + "start.css": "body { background: red; }", + "end.js": function() { + let style = window.getComputedStyle(document.body); + browser.test.assertEq( + "rgb(255, 0, 0)", + style.backgroundColor, + "document_start style should have been applied" + ); + browser.test.sendMessage("content-script-done"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + await extension.awaitMessage("content-script-done"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js new file mode 100644 index 0000000000..4e42181e71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js @@ -0,0 +1,65 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_api_injection() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + web_accessible_resources: ["content_script_iframe.html"], + }, + + files: { + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("content_script_iframe.html"); + document.body.appendChild(iframe); + }, + "content_script_iframe.js"() { + window.location = `http://example.com/data/file_privilege_escalation.html`; + }, + "content_script_iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script type="text/javascript" src="content_script_iframe.js"></script> + </head> + </html>`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let awaitConsole = new Promise(resolve => { + Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + resolve(message); + } + }); + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let message = await awaitConsole; + ok( + message.message.includes( + "WebExt Privilege Escalation: typeof(browser) = undefined" + ), + "Document does not have `browser` APIs." + ); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js new file mode 100644 index 0000000000..cb9a07142d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js @@ -0,0 +1,79 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_async_loading() { + const adder = `(function add(a = 1) { this.count += a; })();\n`; + + const extension = { + manifest: { + content_scripts: [ + { + run_at: "document_start", + matches: ["http://example.com/dummy"], + js: ["first.js", "second.js"], + }, + { + run_at: "document_end", + matches: ["http://example.com/dummy"], + js: ["third.js"], + }, + ], + }, + files: { + "first.js": ` + this.count = 0; + ${adder.repeat(50000)}; // 2Mb + browser.test.assertEq(this.count, 50000, "A 50k line script"); + + this.order = (this.order || 0) + 1; + browser.test.sendMessage("first", this.order); + `, + "second.js": ` + this.order = (this.order || 0) + 1; + browser.test.sendMessage("second", this.order); + `, + "third.js": ` + this.order = (this.order || 0) + 1; + browser.test.sendMessage("third", this.order); + `, + }, + }; + + async function checkOrder(ext) { + const [first, second, third] = await Promise.all([ + ext.awaitMessage("first"), + ext.awaitMessage("second"), + ext.awaitMessage("third"), + ]); + + equal(first, 1, "first.js finished execution first."); + equal(second, 2, "second.js finished execution second."); + equal(third, 3, "third.js finished execution third."); + } + + info("Test pages observed while extension is running"); + const observed = ExtensionTestUtils.loadExtension(extension); + await observed.startup(); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await checkOrder(observed); + await observed.unload(); + + info("Test pages already existing on extension startup"); + const existing = ExtensionTestUtils.loadExtension(extension); + + await existing.startup(); + await checkOrder(existing); + await existing.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js new file mode 100644 index 0000000000..4ac22dc700 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js @@ -0,0 +1,128 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["green.example.com", "red.example.com"], +}); + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/pixel.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(`<!DOCTYPE html> + <script> + function readByWeb() { + let ctx = document.querySelector("canvas").getContext("2d"); + let {data} = ctx.getImageData(0, 0, 1, 1); + return data.slice(0, 3).join(); + } + </script> + `); +}); + +add_task(async function test_contentscript_canvas_tainting() { + async function contentScript() { + let canvas = document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + document.body.appendChild(canvas); + + function draw(url) { + return new Promise(resolve => { + let img = document.createElement("img"); + img.onload = () => { + ctx.drawImage(img, 0, 0, 1, 1); + resolve(); + }; + img.src = url; + }); + } + + function readByExt() { + let { data } = ctx.getImageData(0, 0, 1, 1); + return data.slice(0, 3).join(); + } + + let readByWeb = window.wrappedJSObject.readByWeb; + + // Test reading after drawing an image from the same origin as the web page. + await draw("http://green.example.com/data/pixel_green.gif"); + browser.test.assertEq( + readByWeb(), + "0,255,0", + "Content can read same-origin image" + ); + browser.test.assertEq( + readByExt(), + "0,255,0", + "Extension can read same-origin image" + ); + + // Test reading after drawing a blue pixel data URI from extension content script. + await draw( + "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs=" + ); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Content can't read extension's image" + ); + browser.test.assertEq( + readByExt(), + "0,0,255", + "Extension can read its own image" + ); + + // Test after tainting the canvas with an image from a third party domain. + await draw("http://red.example.com/data/pixel_red.gif"); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Content can't read third party image" + ); + browser.test.assertThrows( + readByExt, + /operation is insecure/, + "Extension can't read fully tainted" + ); + + // Test canvas is still fully tainted after drawing extension's data: image again. + await draw( + "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs=" + ); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Canvas still fully tainted for content" + ); + browser.test.assertThrows( + readByExt, + /operation is insecure/, + "Canvas still fully tainted for extension" + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://green.example.com/pixel.html"], + js: ["cs.js"], + }, + ], + }, + files: { + "cs.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://green.example.com/pixel.html" + ); + await extension.awaitMessage("done"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js new file mode 100644 index 0000000000..2bb30f3c90 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js @@ -0,0 +1,348 @@ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +function loadExtension() { + function contentScript() { + browser.test.sendMessage("content-script-ready"); + + window.addEventListener( + "pagehide", + () => { + browser.test.sendMessage("content-script-hide"); + }, + true + ); + window.addEventListener("pageshow", () => { + browser.test.sendMessage("content-script-show"); + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy*"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); +} + +add_task(async function test_contentscript_context() { + let extension = loadExtension(); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("content-script-ready"); + await extension.awaitMessage("content-script-show"); + + // Get the content script context and check that it points to the correct window. + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + this.context = DocumentManager.getContext(extensionId, this.content); + + Assert.ok(this.context, "Got content script context"); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + // Navigate so that the content page is hidden in the bfcache. + + this.content.location = "http://example.org/dummy"; + }); + + await extension.awaitMessage("content-script-hide"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + }); + + await contentPage.close(); + await extension.awaitMessage("content-script-hide"); + await extension.unload(); +}); + +async function contentscript_context_incognito_not_allowed_test() { + async function background() { + await browser.contentScripts.register({ + js: [{ file: "registered_script.js" }], + matches: ["http://example.com/dummy"], + runAt: "document_start", + }); + + browser.test.sendMessage("background-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + permissions: ["http://example.com/*"], + }, + background, + files: { + "content_script.js": () => { + browser.test.notifyFail("content_script_loaded"); + }, + "registered_script.js": () => { + browser.test.notifyFail("registered_script_loaded"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + let context = DocumentManager.getContext(extensionId, this.content); + Assert.equal( + context, + null, + "Extension unable to use content_script in private browsing window" + ); + }); + + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_contentscript_context_incognito_not_allowed() { + return runWithPrefs( + [["extensions.allowPrivateBrowsingByDefault", false]], + contentscript_context_incognito_not_allowed_test + ); +}); + +add_task(async function test_contentscript_context_unload_while_in_bfcache() { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy?first" + ); + let extension = loadExtension(); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + // Get the content script context and check that it points to the correct window. + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + // Save context so we can verify that contentWindow is nulled after unload. + this.context = DocumentManager.getContext(extensionId, this.content); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + this.contextUnloadedPromise = new Promise(resolve => { + this.context.callOnClose({ close: resolve }); + }); + this.pageshownPromise = new Promise(resolve => { + this.content.addEventListener( + "pageshow", + () => { + // Yield to the event loop once more to ensure that all pageshow event + // handlers have been dispatched before fulfilling the promise. + let { setTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm" + ); + setTimeout(resolve, 0); + }, + { once: true, mozSystemGroup: true } + ); + }); + + // Navigate so that the content page is hidden in the bfcache. + this.content.location = "http://example.org/dummy?second"; + }); + + await extension.awaitMessage("content-script-hide"); + + await extension.unload(); + await contentPage.spawn(null, async () => { + await this.contextUnloadedPromise; + Assert.equal(this.context.unloaded, true, "Context has been unloaded"); + + // Normally, when a page is not in the bfcache, context.contentWindow is + // not null when the callOnClose handler is invoked (this is checked by the + // previous subtest). + // Now wait a little bit and check again to ensure that the contentWindow + // property is not somehow restored. + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + + await this.pageshownPromise; + + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null after restore from bfcache" + ); + }); + + await contentPage.close(); +}); + +add_task(async function test_contentscript_context_valid_during_execution() { + // This test does the following: + // - Load page + // - Load extension; inject content script. + // - Navigate page; pagehide triggered. + // - Navigate back; pageshow triggered. + // - Close page; pagehide, unload triggered. + // At each of these last four events, the validity of the context is checked. + + function contentScript() { + browser.test.sendMessage("content-script-ready"); + window.wrappedJSObject.checkContextIsValid("Context is valid on execution"); + + window.addEventListener( + "pagehide", + () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on pagehide" + ); + browser.test.sendMessage("content-script-hide"); + }, + true + ); + window.addEventListener("pageshow", () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on pageshow" + ); + + // This unload listener is registered after pageshow, to ensure that the + // page can be stored in the bfcache at the previous pagehide. + window.addEventListener("unload", () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on unload" + ); + browser.test.sendMessage("content-script-unload"); + }); + + browser.test.sendMessage("content-script-show"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy*"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy?first" + ); + await contentPage.spawn(extension.id, async extensionId => { + let context; + let checkContextIsValid = description => { + if (!context) { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + context = DocumentManager.getContext(extensionId, this.content); + } + Assert.equal( + context.contentWindow, + this.content, + `${description}: contentWindow` + ); + Assert.equal(context.active, true, `${description}: active`); + }; + Cu.exportFunction(checkContextIsValid, this.content, { + defineAs: "checkContextIsValid", + }); + }); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + await contentPage.spawn(extension.id, async extensionId => { + // Navigate so that the content page is frozen in the bfcache. + this.content.location = "http://example.org/dummy?second"; + }); + + await extension.awaitMessage("content-script-hide"); + await contentPage.spawn(null, async () => { + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + await contentPage.close(); + await extension.awaitMessage("content-script-hide"); + await extension.awaitMessage("content-script-unload"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js new file mode 100644 index 0000000000..1b705e0a53 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js @@ -0,0 +1,160 @@ +"use strict"; + +/* globals exportFunction */ +/* eslint-disable mozilla/balanced-listeners */ + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +server.registerPathHandler("/bfcachetestpage", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html;charset=utf-8", false); + response.write(`<!DOCTYPE html> +<script> + window.addEventListener("pageshow", (event) => { + event.stopImmediatePropagation(); + if (window.browserTestSendMessage) { + browserTestSendMessage("content-script-show"); + } + }); + window.addEventListener("pagehide", (event) => { + event.stopImmediatePropagation(); + if (window.browserTestSendMessage) { + if (event.persisted) { + browserTestSendMessage("content-script-hide"); + } else { + browserTestSendMessage("content-script-unload"); + } + } + }, true); +</script>`); +}); + +add_task(async function test_contentscript_context_isolation() { + function contentScript() { + browser.test.sendMessage("content-script-ready"); + + exportFunction(browser.test.sendMessage, window, { + defineAs: "browserTestSendMessage", + }); + + window.addEventListener("pageshow", () => { + browser.test.fail( + "pageshow should have been suppressed by stopImmediatePropagation" + ); + }); + window.addEventListener( + "pagehide", + () => { + browser.test.fail( + "pagehide should have been suppressed by stopImmediatePropagation" + ); + }, + true + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/bfcachetestpage"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/bfcachetestpage" + ); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + // Get the content script context and check that it points to the correct window. + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + this.context = DocumentManager.getContext(extensionId, this.content); + + Assert.ok(this.context, "Got content script context"); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + // Navigate so that the content page is hidden in the bfcache. + + this.content.location = "http://example.org/dummy?noscripthere1"; + }); + + await extension.awaitMessage("content-script-hide"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + Assert.ok(this.context.sandbox, "Context's sandbox exists"); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + Assert.ok(this.context.sandbox, "Context's sandbox exists before unload"); + + let contextUnloadedPromise = new Promise(resolve => { + this.context.callOnClose({ close: resolve }); + }); + + // Now add an "unload" event listener, which should prevent a page from entering the bfcache. + await new Promise(resolve => { + this.content.addEventListener("unload", () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property should be non-null at unload" + ); + resolve(); + }); + this.content.location = "http://example.org/dummy?noscripthere2"; + }); + + await contextUnloadedPromise; + }); + + await extension.awaitMessage("content-script-unload"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.sandbox, + null, + "Context's sandbox has been destroyed after unload" + ); + }); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js new file mode 100644 index 0000000000..ff2622e4fb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js @@ -0,0 +1,177 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_create_iframe() { + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + let { name, availableAPIs, manifest, testGetManifest } = msg; + let hasExtTabsAPI = availableAPIs.indexOf("tabs") > 0; + let hasExtWindowsAPI = availableAPIs.indexOf("windows") > 0; + + browser.test.assertFalse( + hasExtTabsAPI, + "the created iframe should not be able to use privileged APIs (tabs)" + ); + browser.test.assertFalse( + hasExtWindowsAPI, + "the created iframe should not be able to use privileged APIs (windows)" + ); + + let { + applications: { + gecko: { id: expectedManifestGeckoId }, + }, + } = chrome.runtime.getManifest(); + let { + applications: { + gecko: { id: actualManifestGeckoId }, + }, + } = manifest; + + browser.test.assertEq( + actualManifestGeckoId, + expectedManifestGeckoId, + "the add-on manifest should be accessible from the created iframe" + ); + + let { + applications: { + gecko: { id: testGetManifestGeckoId }, + }, + } = testGetManifest; + + browser.test.assertEq( + testGetManifestGeckoId, + expectedManifestGeckoId, + "GET_MANIFEST() returns manifest data before extension unload" + ); + + browser.test.sendMessage(name); + }); + } + + function contentScriptIframe() { + window.GET_MANIFEST = browser.runtime.getManifest.bind(null); + + window.testGetManifestException = () => { + try { + window.GET_MANIFEST(); + } catch (exception) { + return String(exception); + } + }; + + let testGetManifest = window.GET_MANIFEST(); + + let manifest = browser.runtime.getManifest(); + let availableAPIs = Object.keys(browser).filter(key => browser[key]); + + browser.runtime.sendMessage({ + name: "content-script-iframe-loaded", + availableAPIs, + manifest, + testGetManifest, + }); + } + + const ID = "contentscript@tests.mozilla.org"; + let extensionData = { + manifest: { + applications: { gecko: { id: ID } }, + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + web_accessible_resources: ["content_script_iframe.html"], + }, + + background, + + files: { + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("content_script_iframe.html"); + document.body.appendChild(iframe); + }, + "content_script_iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script type="text/javascript" src="content_script_iframe.js"></script> + </head> + </html>`, + "content_script_iframe.js": contentScriptIframe, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitMessage("content-script-iframe-loaded"); + + info("testing APIs availability once the extension is unloaded..."); + + await contentPage.spawn(null, () => { + this.iframeWindow = this.content[0]; + + Assert.ok(this.iframeWindow, "content script enabled iframe found"); + Assert.ok( + /content_script_iframe\.html$/.test(this.iframeWindow.location), + "the found iframe has the expected URL" + ); + }); + + await extension.unload(); + + info( + "test content script APIs not accessible from the frame once the extension is unloaded" + ); + + await contentPage.spawn(null, () => { + let win = Cu.waiveXrays(this.iframeWindow); + ok( + !Cu.isDeadWrapper(win.browser), + "the API object should not be a dead object" + ); + + let manifest; + let manifestException; + try { + manifest = win.browser.runtime.getManifest(); + } catch (e) { + manifestException = e; + } + + Assert.ok(!manifest, "manifest should be undefined"); + + Assert.equal( + manifestException.constructor.name, + "TypeError", + "expected exception received" + ); + + Assert.ok( + manifestException.message.endsWith("win.browser.runtime is undefined"), + "expected exception received" + ); + + let getManifestException = win.testGetManifestException(); + + Assert.equal( + getManifestException, + "TypeError: can't access dead object", + "expected exception received" + ); + }); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js new file mode 100644 index 0000000000..cf770d91b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js @@ -0,0 +1,355 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ + hosts: ["example.com", "csplog.example.net"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +var gDefaultCSP = `default-src 'self' 'report-sample'; script-src 'self' 'report-sample';`; +var gCSP = gDefaultCSP; +const pageContent = `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body> + <img id="testimg"> + </body> + </html>`; + +server.registerPathHandler("/plain.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (gCSP) { + info(`Content-Security-Policy: ${gCSP}`); + response.setHeader("Content-Security-Policy", gCSP); + } + response.write(pageContent); +}); + +const BASE_URL = `http://example.com`; +const pageURL = `${BASE_URL}/plain.html`; + +const CSP_REPORT_PATH = "/csp-report.sjs"; + +function readUTF8InputStream(stream) { + let buffer = NetUtil.readInputStream(stream, stream.available()); + return new TextDecoder().decode(buffer); +} + +server.registerPathHandler(CSP_REPORT_PATH, (request, response) => { + response.setStatusLine(request.httpVersion, 204, "No Content"); + let data = readUTF8InputStream(request.bodyInputStream); + Services.obs.notifyObservers(null, "extension-test-csp-report", data); +}); + +async function promiseCSPReport(test) { + let res = await TestUtils.topicObserved("extension-test-csp-report", test); + return JSON.parse(res[1]); +} + +// Test functions loaded into extension content script. +function testImage(data = {}) { + return new Promise(resolve => { + let img = window.document.getElementById("testimg"); + img.onload = () => resolve(true); + img.onerror = () => { + browser.test.log(`img error: ${img.src}`); + resolve(false); + }; + img.src = data.image_url; + }); +} + +function testFetch(data = {}) { + let f = data.content ? content.fetch : fetch; + return f(data.url) + .then(() => true) + .catch(e => { + browser.test.assertEq( + e.message, + "NetworkError when attempting to fetch resource.", + "expected fetch failure" + ); + return false; + }); +} + +async function testEval(data = {}) { + try { + // eslint-disable-next-line no-eval + let ev = data.content ? window.eval : eval; + return ev("true"); + } catch (e) { + return false; + } +} + +async function testFunction(data = {}) { + try { + // eslint-disable-next-line no-eval + let fn = data.content ? window.Function : Function; + let sum = new fn("a", "b", "return a + b"); + return sum(1, 1); + } catch (e) { + return 0; + } +} + +function testScriptTag(data) { + return new Promise(resolve => { + let script = document.createElement("script"); + script.src = data.url; + script.onload = () => { + resolve(true); + }; + script.onerror = () => { + resolve(false); + }; + document.body.appendChild(script); + }); +} + +// If the violation source is the extension the securitypolicyviolation event is not fired. +// If the page is the source, the event is fired and both the content script or page scripts +// will receive the event. If we're expecting a moz-extension report we'll fail in the +// event listener if we receive a report. Otherwise we want to resolve in the listener to +// ensure we've received the event for the test. +function contentScript(report) { + return new Promise(resolve => { + if (!report || report["document-uri"] === "moz-extension") { + resolve(); + } + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("securitypolicyviolation", e => { + browser.test.assertTrue( + e.documentURI !== "moz-extension", + `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}` + ); + resolve(); + }); + }); +} + +let TESTS = [ + // Image Tests + { + description: + "Image from content script using default extension csp. Image is allowed.", + pageCSP: `${gDefaultCSP} img-src 'none';`, + script: testImage, + data: { image_url: `${BASE_URL}/data/file_image_good.png` }, + expect: true, + }, + // Fetch Tests + { + description: "Fetch url in content script uses default extension csp.", + pageCSP: `${gDefaultCSP} connect-src 'none';`, + script: testFetch, + data: { url: `${BASE_URL}/data/file_image_good.png` }, + expect: true, + }, + { + description: "Fetch full url from content script uses page csp.", + pageCSP: `${gDefaultCSP} connect-src 'none';`, + script: testFetch, + data: { + content: true, + url: `${BASE_URL}/data/file_image_good.png`, + }, + expect: false, + report: { + "blocked-uri": `${BASE_URL}/data/file_image_good.png`, + "document-uri": `${BASE_URL}/plain.html`, + "violated-directive": "connect-src", + }, + }, + { + description: "Fetch url from content script uses page csp.", + pageCSP: `${gDefaultCSP} connect-src *;`, + script: testFetch, + version: 3, + data: { + content: true, + url: `${BASE_URL}/data/file_image_good.png`, + }, + expect: true, + }, + + // Eval tests. + { + description: "Eval from content script uses page csp with unsafe-eval.", + pageCSP: `default-src 'none'; script-src 'unsafe-eval';`, + script: testEval, + data: { content: true }, + expect: true, + }, + { + description: "Eval from content script uses page csp.", + pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`, + version: 3, + script: testEval, + data: { content: true }, + expect: false, + report: { + "blocked-uri": "eval", + "document-uri": "http://example.com/plain.html", + "violated-directive": "script-src", + }, + }, + { + description: "Eval in content script allowed by v2 csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + script: testEval, + expect: true, + }, + { + description: "Eval in content script disallowed by v3 csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + version: 3, + script: testEval, + expect: false, + }, + { + description: "Wrapped Eval in content script uses page csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + version: 3, + script: async () => { + return window.wrappedJSObject.eval("true"); + }, + expect: true, + }, + { + description: "Wrapped Eval in content script denied by page csp.", + pageCSP: `script-src 'self';`, + version: 3, + script: async () => { + try { + return window.wrappedJSObject.eval("true"); + } catch (e) { + return false; + } + }, + expect: false, + }, + + { + description: "Function from content script uses page csp.", + pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`, + script: testFunction, + data: { content: true }, + expect: 2, + }, + { + description: "Function from content script uses page csp.", + pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`, + version: 3, + script: testFunction, + data: { content: true }, + expect: 0, + report: { + "blocked-uri": "eval", + "document-uri": "http://example.com/plain.html", + "violated-directive": "script-src", + }, + }, + { + description: "Function in content script uses extension csp.", + pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`, + version: 3, + script: testFunction, + expect: 0, + }, + + // The javascript url tests are not included as we do not execute those, + // aparently even with the urlbar filtering pref flipped. + // (browser.urlbar.filter.javascript) + // https://bugzilla.mozilla.org/show_bug.cgi?id=866522 + + // script tag injection tests + { + description: "remote script in content script passes in v2", + version: 2, + pageCSP: "script-src http://example.com:*;", + script: testScriptTag, + data: { url: `${BASE_URL}/data/file_script_good.js` }, + expect: true, + }, + { + description: "remote script in content script fails in v3", + version: 3, + pageCSP: "script-src http://example.com:*;", + script: testScriptTag, + data: { url: `${BASE_URL}/data/file_script_good.js` }, + expect: false, + }, +]; + +async function runCSPTest(test) { + // Set the CSP for the page loaded into the tab. + gCSP = `${test.pageCSP || gDefaultCSP} report-uri ${CSP_REPORT_PATH}`; + let data = { + manifest: { + manifest_version: test.version || 2, + content_scripts: [ + { + matches: ["http://*/plain.html"], + run_at: "document_idle", + js: ["content_script.js"], + }, + ], + permissions: ["<all_urls>"], + }, + + files: { + "content_script.js": ` + (${contentScript})(${JSON.stringify(test.report)}).then(() => { + browser.test.sendMessage("violationEvent"); + }); + (${test.script})(${JSON.stringify(test.data)}).then(result => { + browser.test.sendMessage("result", result); + }); + `, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + let reportPromise = test.report && promiseCSPReport(); + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + info(`running: ${test.description}`); + await extension.awaitMessage("violationEvent"); + let result = await extension.awaitMessage("result"); + equal(result, test.expect, test.description); + if (test.report) { + let report = await reportPromise; + for (let key of Object.keys(test.report)) { + equal( + report["csp-report"][key], + test.report[key], + `csp-report ${key} matches` + ); + } + } + + await extension.unload(); + await contentPage.close(); + clearCache(); +} + +add_task(async function test_contentscript_csp() { + for (let test of TESTS) { + await runCSPTest(test); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js new file mode 100644 index 0000000000..d94023387f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js @@ -0,0 +1,48 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_content_script_css() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + css: ["content.css"], + run_at: "document_start", + }, + ], + }, + + files: { + "content.css": "body { max-width: 42px; }", + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + function task() { + let style = this.content.getComputedStyle(this.content.document.body); + return style.maxWidth; + } + + let maxWidth = await contentPage.spawn(null, task); + equal(maxWidth, "42px", "Stylesheet correctly applied"); + + await extension.unload(); + + maxWidth = await contentPage.spawn(null, task); + equal(maxWidth, "none", "Stylesheet correctly removed"); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js new file mode 100644 index 0000000000..f485a012c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js @@ -0,0 +1,98 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_exportHelpers() { + function contentScript() { + browser.test.assertTrue(typeof cloneInto === "function"); + browser.test.assertTrue(typeof createObjectIn === "function"); + browser.test.assertTrue(typeof exportFunction === "function"); + + /* globals exportFunction, precisePi, reportPi */ + let value = 3.14; + exportFunction(() => value, window, { defineAs: "precisePi" }); + + browser.test.assertEq( + "undefined", + typeof precisePi, + "exportFunction should export to the page's scope only" + ); + + browser.test.assertEq( + "undefined", + typeof window.precisePi, + "exportFunction should export to the page's scope only" + ); + + let results = []; + exportFunction(pi => results.push(pi), window, { defineAs: "reportPi" }); + + let s = document.createElement("script"); + s.textContent = `(${function() { + let result1 = "unknown 1"; + let result2 = "unknown 2"; + try { + result1 = precisePi(); + } catch (e) { + result1 = "err:" + e; + } + try { + result2 = window.precisePi(); + } catch (e) { + result2 = "err:" + e; + } + reportPi(result1); + reportPi(result2); + }})();`; + + document.documentElement.appendChild(s); + // Inline script ought to run synchronously. + + browser.test.assertEq( + 3.14, + results[0], + "exportFunction on window should define a global function" + ); + browser.test.assertEq( + 3.14, + results[1], + "exportFunction on window should export a property to window." + ); + + browser.test.assertEq( + 2, + results.length, + "Expecting the number of results to match the number of method calls" + ); + + browser.test.notifyPass("export helper test completed"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/data/file_sample.html"], + run_at: "document_start", + }, + ], + }, + + files: { + "contentscript.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("export helper test completed"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js new file mode 100644 index 0000000000..1a8aa6d706 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js @@ -0,0 +1,61 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dummyFrame", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write(""); +}); + +add_task(async function connect_from_background_frame() { + async function background() { + const FRAME_URL = "http://example.com:8888/dummyFrame"; + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.sender.tab, undefined, "Sender is not a tab"); + browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL"); + port.onMessage.addListener(msg => { + browser.test.assertEq("pong", msg, "Reply from content script"); + port.disconnect(); + }); + port.postMessage("ping"); + }); + + await browser.contentScripts.register({ + matches: ["http://example.com/dummyFrame"], + js: [{ file: "contentscript.js" }], + allFrames: true, + }); + + let f = document.createElement("iframe"); + f.src = FRAME_URL; + document.body.appendChild(f); + } + + function contentScript() { + browser.test.log(`Running content script at ${document.URL}`); + + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq("ping", msg, "Expected message to content script"); + port.postMessage("pong"); + }); + port.onDisconnect.addListener(() => { + browser.test.sendMessage("disconnected_in_content_script"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + }, + files: { + "contentscript.js": contentScript, + }, + background, + }); + await extension.startup(); + await extension.awaitMessage("disconnected_in_content_script"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js new file mode 100644 index 0000000000..484c41ad3f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js @@ -0,0 +1,71 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["a.example.com", "b.example.com", "c.example.com"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_perf_observers_cors() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://b.example.com/"], + content_scripts: [ + { + matches: ["http://a.example.com/file_sample.html"], + js: ["cs.js"], + }, + ], + }, + files: { + "cs.js"() { + let obs = new window.PerformanceObserver(list => { + list.getEntries().forEach(e => { + browser.test.sendMessage("observed", { + url: e.name, + time: e.connectEnd, + size: e.encodedBodySize, + }); + }); + }); + obs.observe({ entryTypes: ["resource"] }); + + let b = document.createElement("link"); + b.rel = "stylesheet"; + + // Simulate page including a cross-origin resource from b.example.com. + b.wrappedJSObject.href = "http://b.example.com/file_download.txt"; + document.head.appendChild(b); + + let c = document.createElement("link"); + c.rel = "stylesheet"; + + // Simulate page including a cross-origin resource from c.example.com. + c.wrappedJSObject.href = "http://c.example.com/file_download.txt"; + document.head.appendChild(c); + }, + }, + }); + + let page = await ExtensionTestUtils.loadContentPage( + "http://a.example.com/file_sample.html" + ); + await extension.startup(); + + let b = await extension.awaitMessage("observed"); + let c = await extension.awaitMessage("observed"); + + if (b.url.startsWith("http://c.")) { + [c, b] = [b, c]; + } + + ok(b.url.startsWith("http://b."), "Observed resource from b.example.com"); + ok(b.time > 0, "connectionEnd available from b.example.com"); + equal(b.size, 428, "encodedBodySize available from b.example.com"); + + ok(c.url.startsWith("http://c."), "Observed resource from c.example.com"); + equal(c.time, 0, "connectionEnd == 0 from c.example.com"); + equal(c.size, 0, "encodedBodySize == 0 from c.example.com"); + + await extension.unload(); + await page.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js new file mode 100644 index 0000000000..e9b1dbe57c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js @@ -0,0 +1,70 @@ +"use strict"; + +function makeExtension(id, isPrivileged) { + return ExtensionTestUtils.loadExtension({ + isPrivileged, + + manifest: { + applications: { gecko: { id } }, + + permissions: isPrivileged ? ["mozillaAddons"] : [], + + content_scripts: [ + { + matches: ["resource://foo/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js"() { + browser.test.assertEq( + "resource://foo/file_sample.html", + document.documentURI, + `Loaded content script into the correct document (extension: ${browser.runtime.id})` + ); + browser.test.sendMessage(`content-script-${browser.runtime.id}`); + }, + }, + }); +} + +add_task(async function test_contentscript_restrictSchemes() { + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitutionWithFlags( + "foo", + Services.io.newFileURI(do_get_file("data")), + resProto.ALLOW_CONTENT_ACCESS + ); + + let unprivileged = makeExtension("unprivileged@tests.mozilla.org", false); + let privileged = makeExtension("privileged@tests.mozilla.org", true); + + await unprivileged.startup(); + await privileged.startup(); + + unprivileged.onMessage( + "content-script-unprivileged@tests.mozilla.org", + () => { + ok( + false, + "Unprivileged extension executed content script on resource URL" + ); + } + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `resource://foo/file_sample.html` + ); + + await privileged.awaitMessage("content-script-privileged@tests.mozilla.org"); + + await contentPage.close(); + + await privileged.unload(); + await unprivileged.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js new file mode 100644 index 0000000000..e0ed263065 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js @@ -0,0 +1,61 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Test that document_start content scripts don't block script-created +// parsers. +add_task(async function test_contentscript_scriptCreated() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_document_write.html"], + js: ["content_script.js"], + run_at: "document_start", + match_about_blank: true, + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": function() { + if (window === top) { + addEventListener( + "message", + msg => { + browser.test.assertEq( + "ok", + msg.data, + "document.write() succeeded" + ); + browser.test.sendMessage("content-script-done"); + }, + { once: true } + ); + } + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_document_write.html` + ); + + await extension.awaitMessage("content-script-done"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js new file mode 100644 index 0000000000..2bf7981657 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js @@ -0,0 +1,102 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_reload_and_unload() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["contentscript.js"], + }, + ], + }, + + files: { + "contentscript.js"() { + browser.test.sendMessage("contentscript-run"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let events = []; + { + let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + const tabUrl = "http://example.com/data/file_sample.html"; + let contentPage = await ExtensionTestUtils.loadContentPage(tabUrl); + + await extension.awaitMessage("contentscript-run"); + + let contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after loading a content script" + ); + equal( + contextEvents[0].eventType, + "load", + "Create ExtensionContext for content script" + ); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + + await contentPage.spawn(null, () => { + this.content.location.reload(); + }); + await extension.awaitMessage("contentscript-run"); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 2, + "ExtensionContext state changes after reloading a content script" + ); + equal(contextEvents[0].eventType, "unload", "Unload old ExtensionContext"); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + equal( + contextEvents[1].eventType, + "load", + "Create new ExtensionContext for content script" + ); + equal(contextEvents[1].url, tabUrl, "ExtensionContext URL = page"); + + await contentPage.close(); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after unloading a content script" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext after closing the tab with the content script" + ); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js new file mode 100644 index 0000000000..f5df8e61d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js @@ -0,0 +1,1373 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/** + * Tests that various types of inline content elements initiate requests + * with the triggering pringipal of the caller that requested the load, + * and that the correct security policies are applied to the resulting + * loads. + */ + +const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment +); + +// Make sure media pre-loading is enabled on Android so that our <audio> and +// <video> elements trigger the expected requests. +Services.prefs.setIntPref("media.autoplay.default", Ci.nsIAutoplay.ALLOWED); +Services.prefs.setIntPref("media.preload.default", 3); + +// Increase the length of the code samples included in CSP reports so that we +// can correctly validate them. +Services.prefs.setIntPref( + "security.csp.reporting.script-sample.max-length", + 4096 +); + +// Do not trunacate the blocked-uri in CSP reports for frame navigations. +Services.prefs.setBoolPref( + "security.csp.truncate_blocked_uri_for_frame_navigations", + false +); + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer({ + hosts: ["example.com", "csplog.example.net"], +}); + +server.registerDirectory("/data/", do_get_file("data")); + +var gContentSecurityPolicy = null; + +const BASE_URL = `http://example.com`; +const CSP_REPORT_PATH = "/csp-report.sjs"; + +/** + * Registers a static HTML document with the given content at the given + * path in our test HTTP server. + * + * @param {string} path + * @param {string} content + */ +function registerStaticPage(path, content) { + server.registerPathHandler(path, (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (gContentSecurityPolicy) { + response.setHeader("Content-Security-Policy", gContentSecurityPolicy); + } + response.write(content); + }); +} + +/** + * A set of tags which are automatically closed in HTML documents, and + * do not require an explicit closing tag. + */ +const AUTOCLOSE_TAGS = new Set(["img", "input", "link", "source"]); + +/** + * An object describing the elements to create for a specific test. + * + * @typedef {object} ElementTestCase + * @property {Array} element + * A recursive array, describing the element to create, in the + * following format: + * + * ["tagname", {attr: "attrValue"}, + * ["child-tagname", {attr: "value"}], + * ...] + * + * For each test, a DOM tree will be created with this structure. + * A source attribute, with the name `test.srcAttr` and a value + * based on the values of `test.src` and `opts`, will be added to + * the first leaf node encountered. + * @property {string} src + * The relative URL to use as the source of the element. Each + * load of this URL will have a separate set of query parameters + * appended to it, based on the values in `opts`. + * @property {string} [srcAttr = "src"] + * The attribute in which to store the element's source URL. + * @property {string} [srcAttr = "src"] + * The attribute in which to store the element's source URL. + * @property {boolean} [liveSrc = false] + * If true, changing the source attribute after the element has + * been inserted into the document is expected to trigger a new + * load, and that configuration will be tested. + */ + +/** + * Options for this specific configuration of an element test. + * + * @typedef {object} ElementTestOptions + * @property {string} origin + * The origin with which the content is expected to load. This + * may be one of "page", "contentScript", or "extension". The actual load + * of the URL will be tested against the computed origin strings for + * those two contexts. + * @property {string} source + * An arbitrary string which uniquely identifies the source of + * the load. For instance, each of these should have separate + * origin strings: + * + * - An element present in the initial page HTML. + * - An element injected by a page script belonging to web + * content. + * - An element injected by an extension content script. + */ + +/** + * Data describing a test element, which can be used to create a + * corresponding DOM tree. + * + * @typedef {object} ElementData + * @property {string} tagName + * The tag name for the element. + * @property {object} attrs + * A property containing key-value pairs for each of the + * attribute's elements. + * @property {Array<ElementData>} children + * A possibly empty array of element data for child elements. + */ + +/** + * Returns data necessary to create test elements for the given test, + * with the given options. + * + * @param {ElementTestCase} test + * An object describing the elements to create for a specific + * test. This element will be created under various + * circumstances, as described by `opts`. + * @param {ElementTestOptions} opts + * Options for this specific configuration of the test. + * @returns {ElementData} + */ +function getElementData(test, opts) { + let baseURL = typeof BASE_URL !== "undefined" ? BASE_URL : location.href; + + let { srcAttr, src } = test; + + // Absolutify the URL, so it passes sanity checks that ignore + // triggering principals for relative URLs. + src = new URL( + src + + `?origin=${encodeURIComponent(opts.origin)}&source=${encodeURIComponent( + opts.source + )}`, + baseURL + ).href; + + let haveSrc = false; + function rec(element) { + let [tagName, attrs, ...children] = element; + + if (children.length) { + children = children.map(rec); + } else if (!haveSrc) { + attrs = Object.assign({ [srcAttr]: src }, attrs); + haveSrc = true; + } + + return { tagName, attrs, children }; + } + return rec(test.element); +} + +/** + * The result type of the {@see createElement} function. + * + * @typedef {object} CreateElementResult + * @property {Element} elem + * The root element of the created DOM tree. + * @property {Element} srcElem + * The element in the tree to which the source attribute must be + * added. + * @property {string} src + * The value of the source element. + */ + +/** + * Creates a DOM tree for a given test, in a given configuration, as + * understood by {@see getElementData}, but without the `test.srcAttr` + * attribute having been set. The caller must set the value of that + * attribute to the returned `src` value. + * + * There are many different ways most source values can be set + * (DOM attribute, DOM property, ...) and many different contexts + * (content script verses page script). Each test should be run with as + * many variants of these as possible. + * + * @param {ElementTestCase} test + * A test object, as passed to {@see getElementData}. + * @param {ElementTestOptions} opts + * An options object, as passed to {@see getElementData}. + * @returns {CreateElementResult} + */ +function createElement(test, opts) { + let srcElem; + let src; + + function rec({ tagName, attrs, children }) { + let elem = document.createElement(tagName); + + for (let [key, val] of Object.entries(attrs)) { + if (key === test.srcAttr) { + srcElem = elem; + src = val; + } else { + elem.setAttribute(key, val); + } + } + for (let child of children) { + elem.appendChild(rec(child)); + } + return elem; + } + let elem = rec(getElementData(test, opts)); + + return { elem, srcElem, src }; +} + +/** + * Escapes any occurrences of &, ", < or > with XML entities. + * + * @param {string} str + * The string to escape. + * @returns {string} The escaped string. + */ +function escapeXML(str) { + let replacements = { + "&": "&", + '"': """, + "'": "'", + "<": "<", + ">": ">", + }; + return String(str).replace(/[&"''<>]/g, m => replacements[m]); +} + +/** + * A tagged template function which escapes any XML metacharacters in + * interpolated values. + * + * @param {Array<string>} strings + * An array of literal strings extracted from the templates. + * @param {Array} values + * An array of interpolated values extracted from the template. + * @returns {string} + * The result of the escaped values interpolated with the literal + * strings. + */ +function escaped(strings, ...values) { + let result = []; + + for (let [i, string] of strings.entries()) { + result.push(string); + if (i < values.length) { + result.push(escapeXML(values[i])); + } + } + + return result.join(""); +} + +/** + * Converts the given test data, as accepted by {@see getElementData}, + * to an HTML representation. + * + * @param {ElementTestCase} test + * A test object, as passed to {@see getElementData}. + * @param {ElementTestOptions} opts + * An options object, as passed to {@see getElementData}. + * @returns {string} + */ +function toHTML(test, opts) { + function rec({ tagName, attrs, children }) { + let html = [`<${tagName}`]; + for (let [key, val] of Object.entries(attrs)) { + html.push(escaped` ${key}="${val}"`); + } + + html.push(">"); + if (!AUTOCLOSE_TAGS.has(tagName)) { + for (let child of children) { + html.push(rec(child)); + } + + html.push(`</${tagName}>`); + } + return html.join(""); + } + return rec(getElementData(test, opts)); +} + +/** + * Injects various permutations of inline CSS into a content page, from both + * extension content script and content page contexts, and sends a "css-sources" + * message to the test harness describing the injected content for verification. + */ +function testInlineCSS() { + let urls = []; + let sources = []; + + /** + * Constructs the URL of an image to be loaded by the given origin, and + * returns a CSS url() expression for it. + * + * The `name` parameter is an arbitrary name which should describe how the URL + * is loaded. The `opts` object may contain arbitrary properties which + * describe the load. Currently, only `inline` is recognized, and indicates + * that the URL is being used in an inline stylesheet which may be blocked by + * CSP. + * + * The URL and its parameters are recorded, and sent to the parent process for + * verification. + * + * @param {string} origin + * @param {string} name + * @param {object} [opts] + * @returns {string} + */ + let i = 0; + let url = (origin, name, opts = {}) => { + let source = `${origin}-${name}`; + + let { href } = new URL( + `css-${i++}.png?origin=${encodeURIComponent( + origin + )}&source=${encodeURIComponent(source)}`, + location.href + ); + + urls.push(Object.assign({}, opts, { href, origin, source })); + return `url("${href}")`; + }; + + /** + * Registers the given inline CSS source as being loaded by the given origin, + * and returns that CSS text. + * + * @param {string} origin + * @param {string} css + * @returns {string} + */ + let source = (origin, css) => { + sources.push({ origin, css }); + return css; + }; + + /** + * Saves the given function to be run after a short delay, just before sending + * the list of loaded sources to the parent process. + */ + let laters = []; + let later = fn => { + laters.push(fn); + }; + + // Note: When accessing an element through `wrappedJSObject`, the operations + // occur in the content page context, using the content subject principal. + // When accessing it through X-ray wrappers, they happen in the content script + // context, using its subject principal. + + { + let li = document.createElement("li"); + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-first")}` + ) + ); + li.style.wrappedJSObject.listStyleImage = url( + "page", + "li.style.listStyleImage-second" + ); + document.body.appendChild(li); + } + + { + let li = document.createElement("li"); + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-first", { inline: true })}` + ) + ); + li.style.listStyleImage = url( + "contentScript", + "li.style.listStyleImage-second" + ); + document.body.appendChild(li); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-first")}` + ) + ); + later(() => + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-second", { inline: true })}` + ) + ) + ); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-first", { inline: true })}` + ) + ); + later(() => + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-second")}` + ) + ) + ); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.style.cssText = source( + "contentScript", + `background: ${url("contentScript", "li.style.cssText-first")}` + ); + + // TODO: This inline style should be blocked, since our style-src does not + // include 'unsafe-eval', but that is currently unimplemented. + later(() => { + li.style.wrappedJSObject.cssText = `background: ${url( + "page", + "li.style.cssText-second" + )}`; + }); + } + + // Creates a new element, inserts it into the page, and returns its CSS selector. + let divNum = 0; + function getSelector() { + let div = document.createElement("div"); + div.id = `generated-div-${divNum++}`; + document.body.appendChild(div); + return `#${div.id}`; + } + + for (let prop of ["textContent", "innerHTML"]) { + // Test creating <style> element from the extension side and then replacing + // its contents from the content side. + { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url("extension", `style-${prop}-first`)}; }` + ); + document.head.appendChild(style); + + later(() => { + style.wrappedJSObject[prop] = source( + "page", + `${sel} { background: ${url("page", `style-${prop}-second`, { + inline: true, + })}; }` + ); + }); + } + + // Test creating <style> element from the extension side and then appending + // a text node to it. Regardless of whether the append happens from the + // content or extension side, this should cause the principal to be + // forgotten. + let testModifyAfterInject = (name, modifyFunc) => { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url( + "extension", + `style-${name}-${prop}-first` + )}; }` + ); + document.head.appendChild(style); + + later(() => { + modifyFunc( + style, + `${sel} { background: ${url("page", `style-${name}-${prop}-second`, { + inline: true, + })}; }` + ); + source("page", style.textContent); + }); + }; + + testModifyAfterInject("appendChild", (style, css) => { + style.appendChild(document.createTextNode(css)); + }); + + // Test creating <style> element from the extension side and then appending + // to it using insertAdjacentHTML, with the same rules as above. + testModifyAfterInject("insertAdjacentHTML", (style, css) => { + // eslint-disable-next-line no-unsanitized/method + style.insertAdjacentHTML("beforeend", css); + }); + + // And again using insertAdjacentText. + testModifyAfterInject("insertAdjacentText", (style, css) => { + style.insertAdjacentText("beforeend", css); + }); + + // Test creating a style element and then accessing its CSSStyleSheet object. + { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url("extension", `style-${prop}-sheet`)}; }` + ); + document.head.appendChild(style); + + browser.test.assertThrows( + () => style.sheet.wrappedJSObject.cssRules, + /Not allowed to access cross-origin stylesheet/, + "Page content should not be able to access extension-generated CSS rules" + ); + + style.sheet.insertRule( + source( + "extension", + `${sel} { border-image: ${url( + "extension", + `style-${prop}-sheet-insertRule` + )}; }` + ) + ); + } + } + + setTimeout(() => { + for (let fn of laters) { + fn(); + } + browser.test.sendMessage("css-sources", { urls, sources }); + }); +} + +/** + * A function which will be stringified, and run both as a page script + * and an extension content script, to test element injection under + * various configurations. + * + * @param {Array<ElementTestCase>} tests + * A list of test objects, as understood by {@see getElementData}. + * @param {ElementTestOptions} baseOpts + * A base options object, as understood by {@see getElementData}, + * which represents the default values for injections under this + * context. + */ +function injectElements(tests, baseOpts) { + window.addEventListener( + "load", + () => { + if (typeof browser === "object") { + try { + testInlineCSS(); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + } + } + + // Basic smoke test to check that SVG images do not try to create a document + // with an expanded principal, which would cause a crash. + let img = document.createElement("img"); + img.src = "data:image/svg+xml,%3Csvg%2F%3E"; + document.body.appendChild(img); + + let rand = Math.random(); + + // Basic smoke test to check that we don't try to create stylesheets with an + // expanded principal, which would cause a crash when loading font sets. + let cssText = ` + @font-face { + font-family: "DoesNotExist${rand}"; + src: url("fonts/DoesNotExist.${rand}.woff") format("woff"); + font-weight: normal; + font-style: normal; + }`; + + let link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "data:text/css;base64," + btoa(cssText); + document.head.appendChild(link); + + let style = document.createElement("style"); + style.textContent = cssText; + document.head.appendChild(style); + + let overrideOpts = opts => Object.assign({}, baseOpts, opts); + let opts = baseOpts; + + // Build the full element with setAttr, then inject. + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, src); + document.body.appendChild(elem); + } + + // Build the full element with a property setter. + opts = overrideOpts({ source: `${baseOpts.source}-prop` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem[test.srcAttr] = src; + document.body.appendChild(elem); + } + + // Build the element without the source attribute, inject, then set + // it. + opts = overrideOpts({ source: `${baseOpts.source}-attr-after-inject` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + document.body.appendChild(elem); + srcElem.setAttribute(test.srcAttr, src); + } + + // Build the element without the source attribute, inject, then set + // the corresponding property. + opts = overrideOpts({ source: `${baseOpts.source}-prop-after-inject` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + document.body.appendChild(elem); + srcElem[test.srcAttr] = src; + } + + // Build the element with a relative, rather than absolute, URL, and + // make sure it always has the page origin. + opts = overrideOpts({ + source: `${baseOpts.source}-relative-url`, + origin: "page", + }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + // Note: This assumes that the content page and the src URL are + // always at the server root. If that changes, the test will + // timeout waiting for matching requests. + src = src.replace(/.*\//, ""); + srcElem.setAttribute(test.srcAttr, src); + document.body.appendChild(elem); + } + + // If we're in an extension content script, do some additional checks. + if (typeof browser !== "undefined") { + // Build the element without the source attribute, inject, then + // have content set it. + opts = overrideOpts({ + source: `${baseOpts.source}-content-attr-after-inject`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + + document.body.appendChild(elem); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + } + + // Build the full element, then let content inject. + opts = overrideOpts({ + source: `${baseOpts.source}-content-inject-after-attr`, + }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, src); + window.wrappedJSObject.elem = elem; + window.wrappedJSObject.eval(`document.body.appendChild(elem)`); + } + + // Build the element without the source attribute, let content set + // it, then inject. + opts = overrideOpts({ + source: `${baseOpts.source}-inject-after-content-attr`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + document.body.appendChild(elem); + } + + // Build the element with a dummy source attribute, inject, then + // let content change it. + opts = overrideOpts({ + source: `${baseOpts.source}-content-change-after-inject`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, "meh.txt"); + document.body.appendChild(elem); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + } + } + }, + { once: true } + ); +} + +/** + * Stringifies the {@see injectElements} function for use as a page or + * content script. + * + * @param {Array<ElementTestCase>} tests + * A list of test objects, as understood by {@see getElementData}. + * @param {ElementTestOptions} opts + * A base options object, as understood by {@see getElementData}, + * which represents the default values for injections under this + * context. + * @returns {string} + */ +function getInjectionScript(tests, opts) { + return ` + ${getElementData} + ${createElement} + ${testInlineCSS} + (${injectElements})(${JSON.stringify(tests)}, + ${JSON.stringify(opts)}); + `; +} + +/** + * Extracts the "origin" query parameter from the given URL, and returns it, + * along with the URL sans origin parameter. + * + * @param {string} origURL + * @returns {object} + * An object with `origin` and `baseURL` properties, containing the value + * or the URL's "origin" query parameter and the URL with that parameter + * removed, respectively. + */ +function getOriginBase(origURL) { + let url = new URL(origURL); + let origin = url.searchParams.get("origin"); + url.searchParams.delete("origin"); + + return { origin, baseURL: url.href }; +} + +/** + * An object containing sets of base URLs and CSS sources which are present in + * the test page, sorted based on how they should be treated by CSP. + * + * @typedef {object} RequestedURLs + * @property {Set<string>} expectedURLs + * A set of URLs which should be successfully requested by the content + * page. + * @property {Set<string>} forbiddenURLs + * A set of URLs which are present in the content page, but should never + * generate requests. + * @property {Set<string>} blockedURLs + * A set of URLs which are present in the content page, and should be + * blocked by CSP, and reported in a CSP report. + * @property {Set<string>} blockedSources + * A set of inline CSS sources which should be blocked by CSP, and + * reported in a CSP report. + */ + +/** + * Computes a list of expected and forbidden base URLs for the given + * sets of tests and sources. The base URL is the complete request URL + * with the `origin` query parameter removed. + * + * @param {Array<ElementTestCase>} tests + * A list of tests, as understood by {@see getElementData}. + * @param {Object<string, object>} expectedSources + * A set of sources for which each of the above tests is expected + * to generate one request, if each of the properties in the + * value object matches the value of the same property in the + * test object. + * @param {Object<string, object>} [forbiddenSources = {}] + * A set of sources for which requests should never be sent. Any + * matching requests from these sources will cause the test to + * fail. + * @returns {RequestedURLs} + */ +function computeBaseURLs(tests, expectedSources, forbiddenSources = {}) { + let expectedURLs = new Set(); + let forbiddenURLs = new Set(); + + function* iterSources(test, sources) { + for (let [source, attrs] of Object.entries(sources)) { + // if a source defines attributes (e.g. liveSrc in PAGE_SOURCES etc.) then all + // attributes in the source must be matched by the test (see const TEST). + if (Object.keys(attrs).every(attr => attrs[attr] === test[attr])) { + yield `${BASE_URL}/${test.src}?source=${source}`; + } + } + } + + for (let test of tests) { + for (let urlPrefix of iterSources(test, expectedSources)) { + expectedURLs.add(urlPrefix); + } + for (let urlPrefix of iterSources(test, forbiddenSources)) { + forbiddenURLs.add(urlPrefix); + } + } + + return { expectedURLs, forbiddenURLs, blockedURLs: forbiddenURLs }; +} + +/** + * Generates a set of expected and forbidden URLs and sources based on the CSS + * injected by our content script. + * + * @param {object} message + * The "css-sources" message sent by the content script, containing lists + * of CSS sources injected into the page. + * @param {Array<object>} message.urls + * A list of URLs present in styles injected by the content script. + * @param {string} message.urls.*.origin + * The origin of the URL, one of "page", "contentScript", or "extension". + * @param {string} message.urls.*.href + * The URL string. + * @param {boolean} message.urls.*.inline + * If true, the URL is present in an inline stylesheet, which may be + * blocked by CSP prior to parsing, depending on its origin. + * @param {Array<object>} message.sources + * A list of inline CSS sources injected by the content script. + * @param {string} message.sources.*.origin + * The origin of the CSS, one of "page", "contentScript", or "extension". + * @param {string} message.sources.*.css + * The CSS source text. + * @param {boolean} [cspEnabled = false] + * If true, a strict CSP is enabled for this page, and inline page + * sources should be blocked. URLs present in these sources will not be + * expected to generate a CSP report, the inline sources themselves will. + * @param {boolean} [contentCspEnabled = false] + * @returns {RequestedURLs} + */ +function computeExpectedForbiddenURLs( + { urls, sources }, + cspEnabled = false, + contentCspEnabled = false +) { + let expectedURLs = new Set(); + let forbiddenURLs = new Set(); + let blockedURLs = new Set(); + let blockedSources = new Set(); + + for (let { href, origin, inline } of urls) { + let { baseURL } = getOriginBase(href); + if (cspEnabled && origin === "page") { + if (inline) { + forbiddenURLs.add(baseURL); + } else { + blockedURLs.add(baseURL); + } + } else if (contentCspEnabled && origin === "contentScript") { + if (inline) { + forbiddenURLs.add(baseURL); + } + } else { + expectedURLs.add(baseURL); + } + } + + if (cspEnabled) { + for (let { origin, css } of sources) { + if (origin === "page") { + blockedSources.add(css); + } + } + } + + return { expectedURLs, forbiddenURLs, blockedURLs, blockedSources }; +} + +/** + * Awaits the content loads for each of the given expected base URLs, + * and checks that their origin strings are as expected. Triggers a test + * failure if any of the given forbidden URLs is requested. + * + * @param {Promise<object>} urlsPromise + * A promise which resolves to an object containing expected and + * forbidden URL sets, as returned by {@see computeBaseURLs}. + * @param {object<string, string>} origins + * A mapping of origin parameters as they appear in URL query + * strings to the origin strings returned by corresponding + * principals. These values are used to test requests against + * their expected origins. + * @returns {Promise} + * A promise which resolves when all requests have been + * processed. + */ +function awaitLoads(urlsPromise, origins) { + return new Promise(resolve => { + let expectedURLs, forbiddenURLs; + let queuedChannels = []; + + let observer; + + function checkChannel(channel) { + let origURL = channel.URI.spec; + let { baseURL, origin } = getOriginBase(origURL); + + if (forbiddenURLs.has(baseURL)) { + ok(false, `Got unexpected request for forbidden URL ${origURL}`); + } + + if (expectedURLs.has(baseURL)) { + expectedURLs.delete(baseURL); + + equal( + channel.loadInfo.triggeringPrincipal.origin, + origins[origin], + `Got expected origin for URL ${origURL}` + ); + + if (!expectedURLs.size) { + Services.obs.removeObserver(observer, "http-on-modify-request"); + info("Got all expected requests"); + resolve(); + } + } + } + + urlsPromise.then(urls => { + expectedURLs = new Set(urls.expectedURLs); + forbiddenURLs = new Set([...urls.forbiddenURLs, ...urls.blockedURLs]); + + for (let channel of queuedChannels.splice(0)) { + checkChannel(channel.QueryInterface(Ci.nsIChannel)); + } + }); + + observer = (channel, topic, data) => { + if (expectedURLs) { + checkChannel(channel.QueryInterface(Ci.nsIChannel)); + } else { + queuedChannels.push(channel); + } + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + }); +} + +function readUTF8InputStream(stream) { + let buffer = NetUtil.readInputStream(stream, stream.available()); + return new TextDecoder().decode(buffer); +} + +/** + * Awaits CSP reports for each of the given forbidden base URLs. + * Triggers a test failure if any of the given expected URLs triggers a + * report. + * + * @param {Promise<object>} urlsPromise + * A promise which resolves to an object containing expected and + * forbidden URL sets, as returned by {@see computeBaseURLs}. + * @returns {Promise} + * A promise which resolves when all requests have been + * processed. + */ +function awaitCSP(urlsPromise) { + return new Promise(resolve => { + let expectedURLs, blockedURLs, blockedSources; + let queuedRequests = []; + + function checkRequest(request) { + let body = JSON.parse(readUTF8InputStream(request.bodyInputStream)); + let report = body["csp-report"]; + + let origURL = report["blocked-uri"]; + if (origURL !== "inline" && origURL !== "") { + let { baseURL } = getOriginBase(origURL); + + if (expectedURLs.has(baseURL)) { + ok(false, `Got unexpected CSP report for allowed URL ${origURL}`); + } + + if (blockedURLs.has(baseURL)) { + blockedURLs.delete(baseURL); + + ok(true, `Got CSP report for forbidden URL ${origURL}`); + } + } + + let source = report["script-sample"]; + if (source) { + if (blockedSources.has(source)) { + blockedSources.delete(source); + + ok( + true, + `Got CSP report for forbidden inline source ${JSON.stringify( + source + )}` + ); + } + } + + if (!blockedURLs.size && !blockedSources.size) { + ok(true, "Got all expected CSP reports"); + resolve(); + } + } + + urlsPromise.then(urls => { + blockedURLs = new Set(urls.blockedURLs); + blockedSources = new Set(urls.blockedSources); + ({ expectedURLs } = urls); + + for (let request of queuedRequests.splice(0)) { + checkRequest(request); + } + }); + + server.registerPathHandler(CSP_REPORT_PATH, (request, response) => { + response.setStatusLine(request.httpVersion, 204, "No Content"); + + if (expectedURLs) { + checkRequest(request); + } else { + queuedRequests.push(request); + } + }); + }); +} + +/** + * A list of tests to run in each context, as understood by + * {@see getElementData}. + */ +const TESTS = [ + { + element: ["audio", {}], + src: "audio.webm", + }, + { + element: ["audio", {}, ["source", {}]], + src: "audio-source.webm", + }, + // TODO: <frame> element, which requires a frameset document. + { + // the blocked-uri for frame-navigations is the pre-path URI. For the + // purpose of this test we do not strip the blocked-uri by setting the + // preference 'truncate_blocked_uri_for_frame_navigations' + element: ["iframe", {}], + src: "iframe.html", + }, + { + element: ["img", {}], + src: "img.png", + }, + { + element: ["img", {}], + src: "imgset.png", + srcAttr: "srcset", + }, + { + element: ["input", { type: "image" }], + src: "input.png", + }, + { + element: ["link", { rel: "stylesheet" }], + src: "link.css", + srcAttr: "href", + }, + { + element: ["picture", {}, ["source", {}], ["img", {}]], + src: "picture.png", + srcAttr: "srcset", + }, + { + element: ["script", {}], + src: "script.js", + liveSrc: false, + }, + { + element: ["video", {}], + src: "video.webm", + }, + { + element: ["video", {}, ["source", {}]], + src: "video-source.webm", + }, +]; + +for (let test of TESTS) { + if (!test.srcAttr) { + test.srcAttr = "src"; + } + if (!("liveSrc" in test)) { + test.liveSrc = true; + } +} + +/** + * A set of sources for which each of the above tests is expected to + * generate one request, if each of the properties in the value object + * matches the value of the same property in the test object. + */ +// Sources which load with the page context. +const PAGE_SOURCES = { + "contentScript-content-attr-after-inject": { liveSrc: true }, + "contentScript-content-change-after-inject": { liveSrc: true }, + "contentScript-inject-after-content-attr": {}, + "contentScript-relative-url": {}, + pageHTML: {}, + pageScript: {}, + "pageScript-attr-after-inject": {}, + "pageScript-prop": {}, + "pageScript-prop-after-inject": {}, + "pageScript-relative-url": {}, +}; +// Sources which load with the extension context. +const EXTENSION_SOURCES = { + contentScript: {}, + "contentScript-attr-after-inject": { liveSrc: true }, + "contentScript-content-inject-after-attr": {}, + "contentScript-prop": {}, + "contentScript-prop-after-inject": {}, +}; +// When our default content script CSP is applied, only +// liveSrc: true are loading. IOW, the "script" test above +// will fail. +const EXTENSION_SOURCES_CONTENT_CSP = { + contentScript: { liveSrc: true }, + "contentScript-attr-after-inject": { liveSrc: true }, + "contentScript-content-inject-after-attr": { liveSrc: true }, + "contentScript-prop": { liveSrc: true }, + "contentScript-prop-after-inject": { liveSrc: true }, +}; +// All sources. +const SOURCES = Object.assign({}, PAGE_SOURCES, EXTENSION_SOURCES); + +registerStaticPage( + "/page.html", + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + <script nonce="deadbeef"> + ${getInjectionScript(TESTS, { source: "pageScript", origin: "page" })} + </script> + </head> + <body> + ${TESTS.map(test => + toHTML(test, { source: "pageHTML", origin: "page" }) + ).join("\n ")} + </body> + </html>` +); + +function catchViolation() { + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("securitypolicyviolation", e => { + browser.test.assertTrue( + e.documentURI !== "moz-extension", + `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}` + ); + }); +} + +const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: ["http://*/page.html"], + run_at: "document_start", + js: ["violation.js", "content_script.js"], + }, + ], + }, + + files: { + "violation.js": catchViolation, + "content_script.js": getInjectionScript(TESTS, { + source: "contentScript", + origin: "contentScript", + }), + }, +}; + +const pageURL = `${BASE_URL}/page.html`; +const pageURI = Services.io.newURI(pageURL); + +// Merges the sets of expected URL and source data returned by separate +// computedExpectedForbiddenURLs and computedBaseURLs calls. +function mergeSources(a, b) { + return { + expectedURLs: new Set([...a.expectedURLs, ...b.expectedURLs]), + forbiddenURLs: new Set([...a.forbiddenURLs, ...b.forbiddenURLs]), + blockedURLs: new Set([...a.blockedURLs, ...b.blockedURLs]), + blockedSources: a.blockedSources || b.blockedSources, + }; +} + +// Returns a set of origin strings for the given extension and content page, for +// use in verifying request triggering principals. +function getOrigins(extension) { + return { + page: Services.scriptSecurityManager.createContentPrincipal(pageURI, {}) + .origin, + contentScript: Cu.getObjectPrincipal( + Cu.Sandbox([extension.principal, pageURL]) + ).origin, + extension: extension.principal.origin, + }; +} + +/** + * Tests that various types of inline content elements initiate requests + * with the triggering pringipal of the caller that requested the load. + */ +add_task(async function test_contentscript_triggeringPrincipals() { + let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg), + computeBaseURLs(TESTS, SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + let finished = awaitLoads(urlsPromise, origins); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); + + clearCache(); +}); + +/** + * Tests that the correct CSP is applied to loads of inline content + * depending on whether the load was initiated by an extension or the + * content page. + */ +add_task(async function test_contentscript_csp() { + // TODO bug 1408193: We currently don't get the full set of CSP reports when + // running in network scheduling chaos mode. It's not entirely clear why. + let chaosMode = parseInt(env.get("MOZ_CHAOSMODE"), 16); + let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02); + + gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`; + + let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg, true), + computeBaseURLs(TESTS, EXTENSION_SOURCES, PAGE_SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + + let finished = Promise.all([ + awaitLoads(urlsPromise, origins), + checkCSPReports && awaitCSP(urlsPromise), + ]); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); +}); + +/** + * Tests that the correct CSP is applied to loads of inline content + * depending on whether the load was initiated by an extension or the + * content page. + */ +add_task(async function test_extension_contentscript_csp() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + + // TODO bug 1408193: We currently don't get the full set of CSP reports when + // running in network scheduling chaos mode. It's not entirely clear why. + let chaosMode = parseInt(env.get("MOZ_CHAOSMODE"), 16); + let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02); + + gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`; + + let data = { + ...EXTENSION_DATA, + manifest: { + ...EXTENSION_DATA.manifest, + manifest_version: 3, + }, + }; + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg, true, true), + computeBaseURLs(TESTS, EXTENSION_SOURCES_CONTENT_CSP, PAGE_SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + + let finished = Promise.all([ + awaitLoads(urlsPromise, origins), + checkCSPReports && awaitCSP(urlsPromise), + ]); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js new file mode 100644 index 0000000000..dd3ab7846d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function content_script_unregistered_during_loadContentScript() { + let content_scripts = []; + + for (let i = 0; i < 10; i++) { + content_scripts.push({ + matches: ["<all_urls>"], + js: ["dummy.js"], + run_at: "document_start", + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts, + }, + files: { + "dummy.js": function() { + browser.test.sendMessage("content-script-executed"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + info("Wait for all the content scripts to be executed"); + await Promise.all( + content_scripts.map(() => extension.awaitMessage("content-script-executed")) + ); + + const promiseDone = contentPage.spawn([extension.id], extensionId => { + const { ExtensionProcessScript } = ChromeUtils.import( + "resource://gre/modules/ExtensionProcessScript.jsm" + ); + + return new Promise(resolve => { + // This recreates a scenario similar to Bug 1593240 and ensures that the + // related fix doesn't regress. Replacing loadContentScript with a + // function that unregisters all the content scripts make us sure that + // mutating the policy contentScripts doesn't trigger a crash due to + // the invalidation of the contentScripts iterator being used by the + // caller (ExtensionPolicyService::CheckContentScripts). + const { loadContentScript } = ExtensionProcessScript; + ExtensionProcessScript.loadContentScript = async (...args) => { + const policy = WebExtensionPolicy.getByID(extensionId); + let initial = policy.contentScripts.length; + let i = initial; + while (i) { + policy.unregisterContentScript(policy.contentScripts[--i]); + } + Services.tm.dispatchToMainThread(() => + resolve({ + initial, + final: policy.contentScripts.length, + }) + ); + // Call the real loadContentScript method. + return loadContentScript(...args); + }; + }); + }); + + info("Reload the webpage"); + await contentPage.loadURL(`${BASE_URL}/file_sample.html`); + info("Wait for all the content scripts to be executed again"); + await Promise.all( + content_scripts.map(() => extension.awaitMessage("content-script-executed")) + ); + info("No crash triggered as expected"); + + Assert.deepEqual( + await promiseDone, + { initial: content_scripts.length, final: 0 }, + "All content scripts unregistered as expected" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js new file mode 100644 index 0000000000..83cb2f86e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.prefs.setBoolPref("layout.xml.prettyprint", true); + +const BASE_XML = '<?xml version="1.0" encoding="UTF-8"?>'; +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/test.xml", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/xml; charset=utf-8", false); + response.write(`${BASE_XML}\n<note></note>`); +}); + +// Make sure that XML pretty printer runs after content scripts +// that runs at document_start (See Bug 1605657). +add_task(async function content_script_on_xml_prettyprinted_document() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["start.js"], + run_at: "document_start", + }, + ], + }, + files: { + "start.js": async function() { + const el = document.createElement("ext-el"); + document.documentElement.append(el); + if (document.readyState !== "complete") { + await new Promise(resolve => { + document.addEventListener("DOMContentLoaded", resolve, { + once: true, + }); + }); + } + browser.test.sendMessage("content-script-done"); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/test.xml" + ); + + info("Wait content script and xml document to be fully loaded"); + await extension.awaitMessage("content-script-done"); + + info("Verify the xml file is still pretty printed"); + const res = await contentPage.spawn([], () => { + const doc = this.content.document; + const shadowRoot = doc.documentElement.openOrClosedShadowRoot; + const prettyPrintLink = + shadowRoot && + shadowRoot.querySelector("link[href*='XMLPrettyPrint.css']"); + return { + hasShadowRoot: !!shadowRoot, + hasPrettyPrintLink: !!prettyPrintLink, + }; + }); + + Assert.deepEqual( + res, + { hasShadowRoot: true, hasPrettyPrintLink: true }, + "The XML file has the pretty print shadowRoot" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js new file mode 100644 index 0000000000..cab508b040 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js @@ -0,0 +1,85 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.net", "example.org"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_process_switch_cross_origin_frame() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.org/*/file_iframe.html"], + all_frames: true, + js: ["cs.js"], + }, + ], + }, + + background() { + browser.runtime.onConnect.addListener(port => { + port.onMessage.addListener(async () => { + let { url, frameId } = port.sender; + + browser.test.assertTrue(frameId > 0, "sender frameId is ok"); + browser.test.assertTrue( + url.endsWith("file_iframe.html"), + "url is ok" + ); + + port.postMessage(frameId); + port.disconnect(); + }); + }); + }, + + files: { + "cs.js"() { + browser.test.assertEq( + location.href, + "http://example.org/data/file_iframe.html", + "url is ok" + ); + + let frameId; + let port = browser.runtime.connect(); + port.onMessage.addListener(response => { + frameId = response; + }); + port.onDisconnect.addListener(() => { + browser.test.sendMessage("content-script-loaded", frameId); + }); + port.postMessage("hello"); + }, + }, + }); + + await extension.startup(); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.net/data/file_with_xorigin_frame.html" + ); + + const browserProcessId = + contentPage.browser.browsingContext.currentWindowGlobal.domProcess.childID; + + const scriptFrameId = await extension.awaitMessage("content-script-loaded"); + + const children = contentPage.browser.browsingContext.children.map(bc => ({ + browsingContextId: bc.id, + processId: bc.currentWindowGlobal.domProcess.childID, + })); + + Assert.equal(children.length, 1); + Assert.equal(scriptFrameId, children[0].browsingContextId); + + if (contentPage.remoteSubframes) { + Assert.notEqual(browserProcessId, children[0].processId); + } else { + Assert.equal(browserProcessId, children[0].processId); + } + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js new file mode 100644 index 0000000000..7b92d5c4b7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js @@ -0,0 +1,59 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_xrays() { + async function contentScript() { + let unwrapped = window.wrappedJSObject; + + browser.test.assertEq( + "undefined", + typeof test, + "Should not have named X-ray property access" + ); + browser.test.assertEq( + undefined, + window.test, + "Should not have named X-ray property access" + ); + browser.test.assertEq( + "object", + typeof unwrapped.test, + "Should always have non-X-ray named property access" + ); + + browser.test.notifyPass("contentScriptXrays"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish("contentScriptXrays"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js new file mode 100644 index 0000000000..7c06fe33a5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js @@ -0,0 +1,198 @@ +"use strict"; + +const global = this; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); + +var { BaseContext, EventManager } = ExtensionCommon; + +class StubContext extends BaseContext { + constructor() { + let fakeExtension = { id: "test@web.extension" }; + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + logActivity(type, name, data) { + // no-op required by subclass + } + + get cloneScope() { + return this.sandbox; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +add_task(async function test_post_unload_promises() { + let context = new StubContext(); + + let fail = result => { + ok(false, `Unexpected callback: ${result}`); + }; + + // Make sure promises resolve normally prior to unload. + let promises = [ + context.wrapPromise(Promise.resolve()), + context.wrapPromise(Promise.reject({ message: "" })).catch(() => {}), + ]; + + await Promise.all(promises); + + // Make sure promises that resolve after unload do not trigger + // resolution handlers. + + context.wrapPromise(Promise.resolve("resolved")).then(fail); + + context.wrapPromise(Promise.reject({ message: "rejected" })).then(fail, fail); + + context.unload(); + + // The `setTimeout` ensures that we return to the event loop after + // promise resolution, which means we're guaranteed to return after + // any micro-tasks that get enqueued by the resolution handlers above. + await new Promise(resolve => setTimeout(resolve, 0)); +}); + +add_task(async function test_post_unload_listeners() { + let context = new StubContext(); + + let fire; + let manager = new EventManager({ + context, + name: "EventManager", + register: _fire => { + fire = () => { + _fire.async(); + }; + return () => {}; + }, + }); + + let fail = event => { + ok(false, `Unexpected event: ${event}`); + }; + + // Check that event listeners isn't called after it has been removed. + manager.addListener(fail); + + let promise = new Promise(resolve => manager.addListener(resolve)); + + fire(); + + // The `fireSingleton` call ia dispatched asynchronously, so it won't + // have fired by this point. The `fail` listener that we remove now + // should not be called, even though the event has already been + // enqueued. + manager.removeListener(fail); + + // Wait for the remaining listener to be called, which should always + // happen after the `fail` listener would normally be called. + await promise; + + // Check that the event listener isn't called after the context has + // unloaded. + manager.addListener(fail); + + // The `fire` callback always dispatches events + // asynchronously, so we need to test that any pending event callbacks + // aren't fired after the context unloads. We also need to test that + // any `fire` calls that happen *after* the context is unloaded also + // do not trigger callbacks. + fire(); + Promise.resolve().then(fire); + + context.unload(); + + // The `setTimeout` ensures that we return to the event loop after + // promise resolution, which means we're guaranteed to return after + // any micro-tasks that get enqueued by the resolution handlers above. + await new Promise(resolve => setTimeout(resolve, 0)); +}); + +class Context extends BaseContext { + constructor(principal) { + let fakeExtension = { id: "test@web.extension" }; + super("testEnv", fakeExtension); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, { wantXrays: false }); + } + + logActivity(type, name, data) { + // no-op required by subclass + } + + get cloneScope() { + return this.sandbox; + } +} + +let ssm = Services.scriptSecurityManager; +const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin( + "http://www.example.org" +); +const PRINCIPAL2 = ssm.createContentPrincipalFromOrigin( + "http://www.somethingelse.org" +); + +// Test that toJSON() works in the json sandbox +add_task(async function test_stringify_toJSON() { + let context = new Context(PRINCIPAL1); + let obj = Cu.evalInSandbox( + "({hidden: true, toJSON() { return {visible: true}; } })", + context.sandbox + ); + + let stringified = context.jsonStringify(obj); + let expected = JSON.stringify({ visible: true }); + equal( + stringified, + expected, + "Stringified object with toJSON() method is as expected" + ); +}); + +// Test that stringifying in inaccessible property throws +add_task(async function test_stringify_inaccessible() { + let context = new Context(PRINCIPAL1); + let sandbox = context.sandbox; + let sandbox2 = Cu.Sandbox(PRINCIPAL2); + + Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox( + "({ subobject: true })", + sandbox2 + ); + let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox); + Assert.throws(() => { + context.jsonStringify(obj); + }, /Permission denied to access property "toJSON"/); +}); + +add_task(async function test_stringify_accessible() { + // Test that an accessible property from another global is included + let principal = Cu.getObjectPrincipal(Cu.Sandbox([PRINCIPAL1, PRINCIPAL2])); + let context = new Context(principal); + let sandbox = context.sandbox; + let sandbox2 = Cu.Sandbox(PRINCIPAL2); + + Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox( + "({ subobject: true })", + sandbox2 + ); + let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox); + let stringified = context.jsonStringify(obj); + + let expected = JSON.stringify({ local: true, nested: { subobject: true } }); + equal( + stringified, + expected, + "Stringified object with accessible property is as expected" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js new file mode 100644 index 0000000000..521b7db4e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js @@ -0,0 +1,273 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Each of these tests do the following: +// 1. Load document to create an extension context (instance of BaseContext). +// 2. Get weak reference to that context. +// 3. Unload the document. +// 4. Force GC and check that the weak reference has been invalidated. + +async function reloadTopContext(contentPage) { + await contentPage.spawn(null, async () => { + let { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" + ); + let windowNukeObserved = TestUtils.topicObserved("inner-window-nuked"); + info(`Reloading top-level document`); + this.content.location.reload(); + await windowNukeObserved; + info(`Reloaded top-level document`); + }); +} + +async function assertContextReleased(contentPage, description) { + await contentPage.spawn(description, async assertionDescription => { + // Force GC, see https://searchfox.org/mozilla-central/rev/b0275bc977ad7fda615ef34b822bba938f2b16fd/testing/talos/talos/tests/devtools/addon/content/damp.js#84-98 + // and https://searchfox.org/mozilla-central/rev/33c21c060b7f3a52477a73d06ebcb2bf313c4431/xpcom/base/nsMemoryReporterManager.cpp#2574-2585,2591-2594 + let gcCount = 0; + while (gcCount < 30 && this.contextWeakRef.get() !== null) { + ++gcCount; + // The JS engine will sometimes hold IC stubs for function + // environments alive across multiple CCs, which can keep + // closed-over JS objects alive. A shrinking GC will throw those + // stubs away, and therefore side-step the problem. + Cu.forceShrinkingGC(); + Cu.forceCC(); + Cu.forceGC(); + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + } + + // The above loop needs to be repeated at most 3 times according to MinimizeMemoryUsage: + // https://searchfox.org/mozilla-central/rev/6f86cc3479f80ace97f62634e2c82a483d1ede40/xpcom/base/nsMemoryReporterManager.cpp#2644-2647 + Assert.lessOrEqual( + gcCount, + 3, + `Context should have been GCd within a few GC attempts.` + ); + + // Each test will set this.contextWeakRef before unloading the document. + Assert.ok(!this.contextWeakRef.get(), assertionDescription); + }); +} + +add_task(async function test_ContentScriptContextChild_in_child_frame() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_iframe.html"], + js: ["content_script.js"], + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": "browser.test.sendMessage('contentScriptLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + await extension.awaitMessage("contentScriptLoaded"); + + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + let frame = this.content.document.querySelector( + "iframe[src*='file_iframe.html']" + ); + let context = DocumentManager.getContext(extensionId, frame.contentWindow); + + Assert.ok(context, "Got content script context"); + + this.contextWeakRef = Cu.getWeakReference(context); + frame.remove(); + }); + + await assertContextReleased( + contentPage, + "ContentScriptContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ContentScriptContextChild_in_toplevel() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": "browser.test.sendMessage('contentScriptLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension.awaitMessage("contentScriptLoaded"); + + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + let context = DocumentManager.getContext(extensionId, this.content); + + Assert.ok(context, "Got content script context"); + + this.contextWeakRef = Cu.getWeakReference(context); + }); + + await reloadTopContext(contentPage); + await extension.awaitMessage("contentScriptLoaded"); + await assertContextReleased( + contentPage, + "ContentScriptContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ExtensionPageContextChild_in_child_frame() { + let extensionData = { + files: { + "iframe.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "toplevel.html": ` + <!DOCTYPE html><meta charset="utf8"> + <iframe src="iframe.html"></iframe> + `, + "script.js": "browser.test.sendMessage('extensionPageLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("extensionPageLoaded"); + + await contentPage.spawn(extension.id, async extensionId => { + let { ExtensionPageChild } = ChromeUtils.import( + "resource://gre/modules/ExtensionPageChild.jsm" + ); + + let frame = this.content.document.querySelector( + "iframe[src*='iframe.html']" + ); + let innerWindowID = + frame.browsingContext.currentWindowContext.innerWindowId; + let context = ExtensionPageChild.extensionContexts.get(innerWindowID); + + Assert.ok(context, "Got extension page context for child frame"); + + this.contextWeakRef = Cu.getWeakReference(context); + frame.remove(); + }); + + await assertContextReleased( + contentPage, + "ExtensionPageContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ExtensionPageContextChild_in_toplevel() { + let extensionData = { + files: { + "toplevel.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "script.js": "browser.test.sendMessage('extensionPageLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("extensionPageLoaded"); + + await contentPage.spawn(extension.id, async extensionId => { + let { ExtensionPageChild } = ChromeUtils.import( + "resource://gre/modules/ExtensionPageChild.jsm" + ); + + let innerWindowID = this.content.windowGlobalChild.innerWindowId; + let context = ExtensionPageChild.extensionContexts.get(innerWindowID); + + Assert.ok(context, "Got extension page context for top-level document"); + + this.contextWeakRef = Cu.getWeakReference(context); + }); + + await reloadTopContext(contentPage); + await extension.awaitMessage("extensionPageLoaded"); + // For some unknown reason, the context cannot forcidbly be released by the + // garbage collector unless we wait for a short while. + await contentPage.spawn(null, async () => { + let start = Date.now(); + // The treshold was found after running this subtest only, 300 times + // in a release build (100 of xpcshell, xpcshell-e10s and xpcshell-remote). + // With treshold 8, almost half of the tests complete after a 17-18 ms delay. + // With treshold 7, over half of the tests complete after a 13-14 ms delay, + // with 12 failures in 300 tests runs. + // Let's double that number to have a safety margin. + for (let i = 0; i < 15; ++i) { + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + } + info(`Going to GC after waiting for ${Date.now() - start} ms.`); + }); + await assertContextReleased( + contentPage, + "ExtensionPageContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js new file mode 100644 index 0000000000..1c1827c64f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js @@ -0,0 +1,513 @@ +"use strict"; + +do_get_profile(); + +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); + +const CONTAINERS_PREF = "privacy.userContext.enabled"; + +AddonTestUtils.init(this); + +add_task(async function startup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contextualIdentities_without_permissions() { + function background() { + browser.test.assertTrue( + !browser.contextualIdentities, + "contextualIdentities API is not available when the contextualIdentities permission is not required" + ); + browser.test.notifyPass("contextualIdentities_without_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + applications: { + gecko: { id: "testing@thing.com" }, + }, + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities_without_permission"); + await extension.unload(); +}); + +add_task(async function test_contextualIdentity_events() { + async function background() { + function createOneTimeListener(type) { + return new Promise((resolve, reject) => { + try { + browser.test.assertTrue( + type in browser.contextualIdentities, + `Found API object browser.contextualIdentities.${type}` + ); + const listener = change => { + browser.test.assertTrue( + "contextualIdentity" in change, + `Found identity in change` + ); + browser.contextualIdentities[type].removeListener(listener); + resolve(change); + }; + browser.contextualIdentities[type].addListener(listener); + } catch (e) { + reject(e); + } + }); + } + + function assertExpected(expected, container) { + // Number of keys that are added by the APIs + const createdCount = 2; + for (let key of Object.keys(container)) { + browser.test.assertTrue(key in expected, `found property ${key}`); + browser.test.assertEq( + expected[key], + container[key], + `property value for ${key} is correct` + ); + } + const hexMatch = /^#[0-9a-f]{6}$/; + browser.test.assertTrue( + hexMatch.test(expected.colorCode), + "Color code property was expected Hex shape" + ); + const iconMatch = /^resource:\/\/usercontext-content\/[a-z]+[.]svg$/; + browser.test.assertTrue( + iconMatch.test(expected.iconUrl), + "Icon url property was expected shape" + ); + browser.test.assertEq( + Object.keys(expected).length, + Object.keys(container).length + createdCount, + "all expected properties found" + ); + } + + let onCreatePromise = createOneTimeListener("onCreated"); + + let containerObj = { name: "foobar", color: "red", icon: "circle" }; + let ci = await browser.contextualIdentities.create(containerObj); + browser.test.assertTrue(!!ci, "We have an identity"); + const onCreateListenerResponse = await onCreatePromise; + const cookieStoreId = ci.cookieStoreId; + assertExpected( + onCreateListenerResponse.contextualIdentity, + Object.assign(containerObj, { cookieStoreId }) + ); + + let onUpdatedPromise = createOneTimeListener("onUpdated"); + let updateContainerObj = { name: "testing", color: "blue", icon: "dollar" }; + ci = await browser.contextualIdentities.update( + cookieStoreId, + updateContainerObj + ); + browser.test.assertTrue(!!ci, "We have an update identity"); + const onUpdatedListenerResponse = await onUpdatedPromise; + assertExpected( + onUpdatedListenerResponse.contextualIdentity, + Object.assign(updateContainerObj, { cookieStoreId }) + ); + + let onRemovePromise = createOneTimeListener("onRemoved"); + ci = await browser.contextualIdentities.remove( + updateContainerObj.cookieStoreId + ); + browser.test.assertTrue(!!ci, "We have an remove identity"); + const onRemoveListenerResponse = await onRemovePromise; + assertExpected( + onRemoveListenerResponse.contextualIdentity, + Object.assign(updateContainerObj, { cookieStoreId }) + ); + + browser.test.notifyPass("contextualIdentities_events"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + useAddonManager: "temporary", + manifest: { + applications: { + gecko: { id: "testing@thing.com" }, + }, + permissions: ["contextualIdentities"], + }, + }); + + Services.prefs.setBoolPref(CONTAINERS_PREF, true); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities_events"); + await extension.unload(); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_with_permissions() { + const initial = Services.prefs.getBoolPref(CONTAINERS_PREF); + + async function background() { + let ci; + await browser.test.assertRejects( + browser.contextualIdentities.get("foobar"), + "Invalid contextual identity: foobar", + "API should reject here" + ); + await browser.test.assertRejects( + browser.contextualIdentities.update("foobar", { name: "testing" }), + "Invalid contextual identity: foobar", + "API should reject for unknown updates" + ); + await browser.test.assertRejects( + browser.contextualIdentities.remove("foobar"), + "Invalid contextual identity: foobar", + "API should reject for removing unknown containers" + ); + + ci = await browser.contextualIdentities.get("firefox-container-1"); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertTrue("name" in ci, "We have an identity.name"); + browser.test.assertTrue("color" in ci, "We have an identity.color"); + browser.test.assertTrue("icon" in ci, "We have an identity.icon"); + browser.test.assertEq("Personal", ci.name, "identity.name is correct"); + browser.test.assertEq( + "firefox-container-1", + ci.cookieStoreId, + "identity.cookieStoreId is correct" + ); + + function listenForMessage(messageName, stateChangeBool) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg) { + browser.test.log(`Got message from background: ${msg}`); + if (msg === messageName + "-response") { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }); + browser.test.log( + `Sending message to background: ${messageName} ${stateChangeBool}` + ); + browser.test.sendMessage(messageName, stateChangeBool); + }); + } + + await listenForMessage("containers-state-change", false); + + browser.test.assertRejects( + browser.contextualIdentities.query({}), + "Contextual identities are currently disabled", + "Throws when containers are disabled" + ); + + await listenForMessage("containers-state-change", true); + + let cis = await browser.contextualIdentities.query({}); + browser.test.assertEq( + 4, + cis.length, + "by default we should have 4 containers" + ); + + cis = await browser.contextualIdentities.query({ name: "Personal" }); + browser.test.assertEq( + 1, + cis.length, + "by default we should have 1 container called Personal" + ); + + cis = await browser.contextualIdentities.query({ name: "foobar" }); + browser.test.assertEq( + 0, + cis.length, + "by default we should have 0 container called foobar" + ); + + ci = await browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "gift", + }); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("foobar", ci.name, "identity.name is correct"); + browser.test.assertEq("red", ci.color, "identity.color is correct"); + browser.test.assertEq("gift", ci.icon, "identity.icon is correct"); + browser.test.assertTrue( + !!ci.cookieStoreId, + "identity.cookieStoreId is correct" + ); + + browser.test.assertRejects( + browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "firefox", + }), + "Invalid icon firefox for container", + "Create container called with an invalid icon" + ); + + browser.test.assertRejects( + browser.contextualIdentities.create({ + name: "foobar", + color: "firefox-orange", + icon: "gift", + }), + "Invalid color name firefox-orange for container", + "Create container called with an invalid color" + ); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq( + 5, + cis.length, + "we should still have have 5 containers" + ); + + ci = await browser.contextualIdentities.get(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("foobar", ci.name, "identity.name is correct"); + browser.test.assertEq("red", ci.color, "identity.color is correct"); + browser.test.assertEq("gift", ci.icon, "identity.icon is correct"); + + browser.test.assertRejects( + browser.contextualIdentities.update(ci.cookieStoreId, { + name: "foobar", + color: "red", + icon: "firefox", + }), + "Invalid icon firefox for container", + "Create container called with an invalid icon" + ); + + browser.test.assertRejects( + browser.contextualIdentities.update(ci.cookieStoreId, { + name: "foobar", + color: "firefox-orange", + icon: "gift", + }), + "Invalid color name firefox-orange for container", + "Create container called with an invalid color" + ); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(5, cis.length, "now we have 5 identities"); + + ci = await browser.contextualIdentities.update(ci.cookieStoreId, { + name: "barfoo", + color: "blue", + icon: "cart", + }); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + ci = await browser.contextualIdentities.get(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + ci = await browser.contextualIdentities.remove(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(4, cis.length, "we are back to 4 identities"); + + browser.test.notifyPass("contextualIdentities"); + } + + function makeExtension(id) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + applications: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + + let extension = makeExtension("containers-test@mozilla.org"); + + extension.onMessage("containers-state-change", stateBool => { + Cu.reportError(`Got message "containers-state-change", ${stateBool}`); + Services.prefs.setBoolPref(CONTAINERS_PREF, stateBool); + Cu.reportError("Changed pref"); + extension.sendMessage("containers-state-change-response"); + }); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + await extension.unload(); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + initial, + "Pref should now be initial state" + ); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_extensions_enable_containers() { + const initial = Services.prefs.getBoolPref(CONTAINERS_PREF); + async function background() { + let ci = await browser.contextualIdentities.get("firefox-container-1"); + browser.test.assertTrue(!!ci, "We have an identity"); + + browser.test.notifyPass("contextualIdentities"); + } + function makeExtension(id) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + applications: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + async function testSetting(expect, message) { + let setting = await ExtensionPreferencesManager.getSetting( + "privacy.containers" + ); + if (expect === null) { + equal(setting, null, message); + } else { + equal(setting.value, expect, message); + } + } + function testPref(expect, message) { + equal(Services.prefs.getBoolPref(CONTAINERS_PREF), expect, message); + } + + let extension = makeExtension("containers-test@mozilla.org"); + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + await extension.unload(); + await testSetting(null, "setting should be unset"); + testPref(initial, "setting should be initial value"); + + // Lets set containers explicitly to be off and test we keep it that way after removal + Services.prefs.setBoolPref(CONTAINERS_PREF, false); + + let extension1 = makeExtension("containers-test-1@mozilla.org"); + await extension1.startup(); + await extension1.awaitFinish("contextualIdentities"); + await testSetting(extension1.id, "setting should be controlled"); + testPref(true, "Pref should now be enabled, whatever it's initial state"); + + await extension1.unload(); + await testSetting(null, "setting should be unset"); + testPref(false, "Pref should be false"); + + // Lets set containers explicitly to be on and test we keep it that way after removal. + Services.prefs.setBoolPref(CONTAINERS_PREF, true); + + let extension2 = makeExtension("containers-test-2@mozilla.org"); + let extension3 = makeExtension("containers-test-3@mozilla.org"); + await extension2.startup(); + await extension2.awaitFinish("contextualIdentities"); + await extension3.startup(); + await extension3.awaitFinish("contextualIdentities"); + + // Flip the ordering to check it's still enabled + await testSetting(extension3.id, "setting should still be controlled by 3"); + testPref(true, "Pref should now be enabled 1"); + await extension3.unload(); + await testSetting(extension2.id, "setting should still be controlled by 2"); + testPref(true, "Pref should now be enabled 2"); + await extension2.unload(); + await testSetting(null, "setting should be unset"); + testPref(true, "Pref should now be enabled 3"); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_preference_change() { + async function background() { + let extensionInfo = await browser.management.getSelf(); + if (extensionInfo.version == "1.0.0") { + const containers = await browser.contextualIdentities.query({}); + browser.test.assertEq( + containers.length, + 4, + "We still have the original containers" + ); + await browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "circle", + }); + } + const containers = await browser.contextualIdentities.query({}); + browser.test.assertEq(containers.length, 5, "We have a new container"); + if (extensionInfo.version == "1.1.0") { + await browser.contextualIdentities.remove(containers[4].cookieStoreId); + } + browser.test.notifyPass("contextualIdentities"); + } + function makeExtension(id, version) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + version, + applications: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + + Services.prefs.setBoolPref(CONTAINERS_PREF, false); + let extension = makeExtension("containers-pref-test@mozilla.org", "1.0.0"); + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + + let extension2 = makeExtension("containers-pref-test@mozilla.org", "1.1.0"); + await extension2.startup(); + await extension2.awaitFinish("contextualIdentities"); + + await extension.unload(); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + false, + "Pref should now be the initial state we set it to." + ); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js new file mode 100644 index 0000000000..8edd61fc63 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js @@ -0,0 +1,675 @@ +"use strict"; + +const { UrlClassifierTestUtils } = ChromeUtils.import( + "resource://testing-common/UrlClassifierTestUtils.jsm" +); + +const { + // cookieBehavior constants. + BEHAVIOR_REJECT, + BEHAVIOR_REJECT_TRACKER, + + // lifetimePolicy constants. + ACCEPT_SESSION, +} = Ci.nsICookieService; + +function createPage({ script, body = "" } = {}) { + if (script) { + body += `<script src="${script}"></script>`; + } + + return `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + ${body} + </body> + </html>`; +} + +const server = createHttpServer({ hosts: ["example.com", "itisatracker.org"] }); +server.registerDirectory("/data/", do_get_file("data")); +server.registerPathHandler("/test-cookies", (request, response) => { + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "text/json", false); + response.setHeader("Set-Cookie", "myKey=myCookie", true); + response.write('{"success": true}'); +}); +server.registerPathHandler("/subframe.html", (request, response) => { + response.write(createPage()); +}); +server.registerPathHandler("/page-with-tracker.html", (request, response) => { + response.write( + createPage({ + body: `<iframe src="http://itisatracker.org/test-cookies"></iframe>`, + }) + ); +}); +server.registerPathHandler("/sw.js", (request, response) => { + response.setHeader("Content-Type", "text/javascript", false); + response.write(""); +}); + +function assertCookiesForHost(url, cookiesCount, message) { + const { host } = new URL(url); + const cookies = Services.cookies.cookies.filter( + cookie => cookie.host === host + ); + equal(cookies.length, cookiesCount, message); + return cookies; +} + +// Test that the indexedDB and localStorage are allowed in an extension page +// and that the indexedDB is allowed in a extension worker. +add_task(async function test_ext_page_allowed_storage() { + function testWebStorages() { + const url = window.location.href; + + try { + // In a webpage accessing indexedDB throws on cookiesBehavior reject, + // here we verify that doesn't happen for an extension page. + browser.test.assertTrue( + indexedDB, + "IndexedDB global should be accessible" + ); + + // In a webpage localStorage is undefined on cookiesBehavior reject, + // here we verify that doesn't happen for an extension page. + browser.test.assertTrue( + localStorage, + "localStorage global should be defined" + ); + + const worker = new Worker("worker.js"); + worker.onmessage = event => { + browser.test.assertTrue( + event.data.pass, + "extension page worker have access to indexedDB" + ); + + browser.test.sendMessage("test-storage:done", url); + }; + + worker.postMessage({}); + } catch (err) { + browser.test.fail(`Unexpected error: ${err}`); + browser.test.sendMessage("test-storage:done", url); + } + } + + function testWorker() { + this.onmessage = () => { + try { + void indexedDB; + postMessage({ pass: true }); + } catch (err) { + postMessage({ pass: false }); + throw err; + } + }; + } + + async function createExtension() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "test_web_storages.js": testWebStorages, + "worker.js": testWorker, + "page_subframe.html": createPage({ script: "test_web_storages.js" }), + "page_with_subframe.html": createPage({ + body: '<iframe src="page_subframe.html"></iframe>', + }), + "page.html": createPage({ + script: "test_web_storages.js", + }), + }, + }); + + await extension.startup(); + + const EXT_BASE_URL = `moz-extension://${extension.uuid}/`; + + return { extension, EXT_BASE_URL }; + } + + const cookieBehaviors = [ + "BEHAVIOR_LIMIT_FOREIGN", + "BEHAVIOR_REJECT_FOREIGN", + "BEHAVIOR_REJECT", + "BEHAVIOR_REJECT_TRACKER", + "BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN", + ]; + equal( + cookieBehaviors.length, + Ci.nsICookieService.BEHAVIOR_LAST, + "all behaviors should be covered" + ); + + for (const behavior of cookieBehaviors) { + info( + `Test extension page access to indexedDB & localStorage with ${behavior}` + ); + ok( + behavior in Ci.nsICookieService, + `${behavior} is a valid CookieBehavior` + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService[behavior] + ); + + // Create a new extension to ensure that the cookieBehavior just set is going to be + // used for the requests triggered by the extension page. + const { extension, EXT_BASE_URL } = await createExtension(); + const extPage = await ExtensionTestUtils.loadContentPage("about:blank", { + extension, + remote: extension.extension.remote, + }); + + info("Test from a top level extension page"); + await extPage.loadURL(`${EXT_BASE_URL}page.html`); + + let testedFromURL = await extension.awaitMessage("test-storage:done"); + equal( + testedFromURL, + `${EXT_BASE_URL}page.html`, + "Got the results from the expected url" + ); + + info("Test from a sub frame extension page"); + await extPage.loadURL(`${EXT_BASE_URL}page_with_subframe.html`); + + testedFromURL = await extension.awaitMessage("test-storage:done"); + equal( + testedFromURL, + `${EXT_BASE_URL}page_subframe.html`, + "Got the results from the expected url" + ); + + await extPage.close(); + await extension.unload(); + } +}); + +add_task(async function test_ext_page_3rdparty_cookies() { + // Disable tracking protection to test cookies on BEHAVIOR_REJECT_TRACKER + // (otherwise tracking protection would block the tracker iframe and + // we would not be actually checking the cookie behavior). + Services.prefs.setBoolPref("privacy.trackingprotection.enabled", false); + await UrlClassifierTestUtils.addTestTrackers(); + registerCleanupFunction(function() { + UrlClassifierTestUtils.cleanupTestTrackers(); + Services.prefs.clearUserPref("privacy.trackingprotection.enabled"); + Services.cookies.removeAll(); + }); + + function testRequestScript() { + browser.test.onMessage.addListener((msg, url) => { + const done = () => { + browser.test.sendMessage(`${msg}:done`); + }; + + switch (msg) { + case "xhr": { + let req = new XMLHttpRequest(); + req.onload = done; + req.open("GET", url); + req.send(); + break; + } + case "fetch": { + window.fetch(url).then(done); + break; + } + case "worker fetch": { + const worker = new Worker("test_worker.js"); + worker.onmessage = evt => { + if (evt.data.requestDone) { + done(); + } + }; + worker.postMessage({ url }); + break; + } + default: { + browser.test.fail(`Received an unexpected message: ${msg}`); + done(); + } + } + }); + + browser.test.sendMessage("testRequestScript:ready", window.location.href); + } + + function testWorker() { + this.onmessage = evt => { + fetch(evt.data.url).then(() => { + postMessage({ requestDone: true }); + }); + }; + } + + async function createExtension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*", "http://itisatracker.org/*"], + }, + files: { + "test_worker.js": testWorker, + "test_request.js": testRequestScript, + "page_subframe.html": createPage({ script: "test_request.js" }), + "page_with_subframe.html": createPage({ + body: '<iframe src="page_subframe.html"></iframe>', + }), + "page.html": createPage({ script: "test_request.js" }), + }, + }); + + await extension.startup(); + + const EXT_BASE_URL = `moz-extension://${extension.uuid}`; + + return { extension, EXT_BASE_URL }; + } + + const testUrl = "http://example.com/test-cookies"; + const testRequests = ["xhr", "fetch", "worker fetch"]; + const tests = [ + { behavior: "BEHAVIOR_ACCEPT", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT_FOREIGN", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT", cookiesCount: 0 }, + { behavior: "BEHAVIOR_LIMIT_FOREIGN", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT_TRACKER", cookiesCount: 1 }, + ]; + + function clearAllCookies() { + Services.cookies.removeAll(); + let cookies = Services.cookies.cookies; + equal(cookies.length, 0, "There shouldn't be any cookies after clearing"); + } + + async function runTestRequests(extension, cookiesCount, msg) { + for (const testRequest of testRequests) { + clearAllCookies(); + extension.sendMessage(testRequest, testUrl); + await extension.awaitMessage(`${testRequest}:done`); + assertCookiesForHost( + testUrl, + cookiesCount, + `${msg}: cookies count on ${testRequest} "${testUrl}"` + ); + } + } + + for (const { behavior, cookiesCount } of tests) { + info(`Test cookies on http requests with ${behavior}`); + ok( + behavior in Ci.nsICookieService, + `${behavior} is a valid CookieBehavior` + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService[behavior] + ); + + // Create a new extension to ensure that the cookieBehavior just set is going to be + // used for the requests triggered by the extension page. + const { extension, EXT_BASE_URL } = await createExtension(); + + // Run all the test requests on a top level extension page. + let extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/page.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("testRequestScript:ready"); + await runTestRequests( + extension, + cookiesCount, + `Test top level extension page on ${behavior}` + ); + await extPage.close(); + + // Rerun all the test requests on a sub frame extension page. + extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/page_with_subframe.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("testRequestScript:ready"); + await runTestRequests( + extension, + cookiesCount, + `Test sub frame extension page on ${behavior}` + ); + await extPage.close(); + + await extension.unload(); + } + + // Test tracking url blocking from a webpage subframe. + info( + "Testing blocked tracker cookies in webpage subframe on BEHAVIOR_REJECT_TRACKERS" + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + BEHAVIOR_REJECT_TRACKER + ); + + const trackerURL = "http://itisatracker.org/test-cookies"; + const { extension, EXT_BASE_URL } = await createExtension(); + const extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/_generated_background_page.html`, + { + extension, + remote: extension.extension.remote, + } + ); + clearAllCookies(); + + await extPage.spawn( + "http://example.com/page-with-tracker.html", + async iframeURL => { + const iframe = this.content.document.createElement("iframe"); + iframe.setAttribute("src", iframeURL); + return new Promise(resolve => { + iframe.onload = () => resolve(); + this.content.document.body.appendChild(iframe); + }); + } + ); + + assertCookiesForHost( + trackerURL, + 0, + "Test cookies on web subframe inside top level extension page on BEHAVIOR_REJECT_TRACKER" + ); + clearAllCookies(); + + await extPage.close(); + await extension.unload(); +}); + +// Test that a webpage embedded as a subframe of an extension page is not allowed to use +// IndexedDB and register a ServiceWorker when it shouldn't be based on the cookieBehavior. +add_task( + async function test_webpage_subframe_storage_respect_cookiesBehavior() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + web_accessible_resources: ["subframe.html"], + }, + files: { + "toplevel.html": createPage({ + body: ` + <iframe id="ext" src="subframe.html"></iframe> + <iframe id="web" src="http://example.com/subframe.html"></iframe> + `, + }), + "subframe.html": createPage(), + }, + }); + + Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT); + + await extension.startup(); + + let extensionPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + + let results = await extensionPage.spawn(null, async () => { + let extFrame = this.content.document.querySelector("iframe#ext"); + let webFrame = this.content.document.querySelector("iframe#web"); + + function testIDB(win) { + try { + void win.indexedDB; + return { success: true }; + } catch (err) { + return { error: `${err}` }; + } + } + + async function testServiceWorker(win) { + try { + await win.navigator.serviceWorker.register("sw.js"); + return { success: true }; + } catch (err) { + return { error: `${err}` }; + } + } + + return { + extTopLevel: testIDB(this.content), + extSubFrame: testIDB(extFrame.contentWindow), + webSubFrame: testIDB(webFrame.contentWindow), + webServiceWorker: await testServiceWorker(webFrame.contentWindow), + }; + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/subframe.html" + ); + + results.extSubFrameContent = await contentPage.spawn( + extension.uuid, + uuid => { + return new Promise(resolve => { + let frame = this.content.document.createElement("iframe"); + frame.setAttribute("src", `moz-extension://${uuid}/subframe.html`); + frame.onload = () => { + try { + void frame.contentWindow.indexedDB; + resolve({ success: true }); + } catch (err) { + resolve({ error: `${err}` }); + } + }; + this.content.document.body.appendChild(frame); + }); + } + ); + + Assert.deepEqual( + results.extTopLevel, + { success: true }, + "IndexedDB allowed in a top level extension page" + ); + + Assert.deepEqual( + results.extSubFrame, + { success: true }, + "IndexedDB allowed in a subframe extension page with a top level extension page" + ); + + Assert.deepEqual( + results.webSubFrame, + { error: "SecurityError: The operation is insecure." }, + "IndexedDB not allowed in a subframe webpage with a top level extension page" + ); + Assert.deepEqual( + results.webServiceWorker, + { error: "SecurityError: The operation is insecure." }, + "IndexedDB and Cache not allowed in a service worker registered in the subframe webpage extension page" + ); + + Assert.deepEqual( + results.extSubFrameContent, + { success: true }, + "IndexedDB allowed in a subframe extension page with a top level webpage" + ); + + await extensionPage.close(); + await contentPage.close(); + + await extension.unload(); + } +); + +// Test that the webpage's indexedDB and localStorage are still not allowed from a content script +// when the cookie behavior doesn't allow it, even when they are allowed in the extension pages. +add_task(async function test_content_script_on_cookieBehaviorReject() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT); + + function contentScript() { + // Ensure that when the current cookieBehavior doesn't allow a webpage to use indexedDB + // or localStorage, then a WebExtension content script is not allowed to use it as well. + browser.test.assertThrows( + () => indexedDB, + /The operation is insecure/, + "a content script can't use indexedDB from a page where it is disallowed" + ); + + browser.test.assertThrows( + () => localStorage, + /The operation is insecure/, + "a content script can't use localStorage from a page where it is disallowed" + ); + + browser.test.notifyPass("cs_disallowed_storage"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("cs_disallowed_storage"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(function clear_cookieBehavior_pref() { + Services.prefs.clearUserPref("network.cookie.cookieBehavior"); +}); + +// Test that localStorage is not in session-only mode for the extension pages, +// even when the session-only mode has been globally enabled, but that the +// lifetime policy currently set is respected in webpage subframes embedded in +// an extension page. +add_task(async function test_localStorage_on_session_lifetimePolicy() { + // localStorage in session-only mode. + Services.prefs.setIntPref("network.cookie.lifetimePolicy", ACCEPT_SESSION); + + function extPageScript() { + localStorage.setItem("test-key", "test-value"); + + browser.test.sendMessage("bg_localStorage_set"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*", "http://itisatracker.org/*"], + }, + files: { + "ext.js": extPageScript, + "ext.html": createPage({ + body: `<iframe src="http://example.com"></iframe>`, + script: "ext.js", + }), + }, + }); + + await extension.startup(); + + let extensionPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/ext.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("bg_localStorage_set"); + + const results = await extensionPage.spawn(null, async () => { + const iframe = this.content.document.querySelector("iframe").contentWindow; + const { localStorage } = this.content; + + await this.content.fetch("http://itisatracker.org/test-cookies"); + await iframe.fetch("http://example.com/test-cookies"); + + return { + topLevel: { + isSessionOnly: localStorage.isSessionOnly, + domStorageLength: localStorage.length, + domStorageStoredValue: localStorage.getItem("test-key"), + }, + webFrame: { + isSessionOnly: iframe.localStorage.isSessionOnly, + }, + }; + }); + + equal( + results.topLevel.isSessionOnly, + false, + "the extension localStorage is not set in session-only mode" + ); + equal( + results.topLevel.domStorageLength, + 1, + "the extension storage contains the expected number of keys" + ); + equal( + results.topLevel.domStorageStoredValue, + "test-value", + "the extension storage contains the expected data" + ); + + equal( + results.webFrame.isSessionOnly, + true, + "the webpage sub frame localStorage is in session-only mode" + ); + + let cookies = assertCookiesForHost( + "http://example.com", + 1, + "Got a cookie from the extension page request" + ); + ok( + cookies[0].isSession, + "Got a session cookie from the extension page request" + ); + + cookies = assertCookiesForHost( + "http://itisatracker.org", + 1, + "Got a cookie from the web page request" + ); + ok(cookies[0].isSession, "Got a session cookie from the web page request"); + + await extensionPage.close(); + + await extension.unload(); +}); + +add_task(function clear_lifetimePolicy_pref() { + Services.prefs.clearUserPref("network.cookie.lifetimePolicy"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js new file mode 100644 index 0000000000..700794b46c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js @@ -0,0 +1,334 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.org", "example.net", "example.com"], +}); + +function promiseSetCookies() { + return new Promise(resolve => { + server.registerPathHandler("/setCookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Set-Cookie", "none=a; sameSite=none", true); + response.setHeader("Set-Cookie", "lax=b; sameSite=lax", true); + response.setHeader("Set-Cookie", "strict=c; sameSite=strict", true); + response.write("<html></html>"); + resolve(); + }); + }); +} + +function promiseLoadedCookies() { + return new Promise(resolve => { + let cookies; + + server.registerPathHandler("/checkCookies", (request, response) => { + cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + + response.setStatusLine(request.httpVersion, 302, "Moved Permanently"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Location", "/ready"); + }); + + server.registerPathHandler("/navigate", (request, response) => { + cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><script>location = '/checkCookies';</script></html>" + ); + }); + + server.registerPathHandler("/fetch", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html><script>fetch('/checkCookies');</script></html>"); + }); + + server.registerPathHandler("/nestedfetch", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><iframe src='http://example.net/nestedfetch2'></iframe></html>" + ); + }); + + server.registerPathHandler("/nestedfetch2", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><iframe src='http://example.org/fetch'></iframe></html>" + ); + }); + + server.registerPathHandler("/ready", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html></html>"); + + resolve(cookies); + }); + }); +} + +add_task(async function setup() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true); + + // We don't want to have 'secure' cookies because our test http server doesn't run in https. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); + + // Let's set 3 cookies before loading the extension. + let cookiesPromise = promiseSetCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/setCookies" + ); + await cookiesPromise; + await contentPage.close(); + Assert.equal(Services.cookies.cookies.length, 3); +}); + +add_task(async function test_cookies_firstParty() { + async function pageScript() { + const ifr = document.createElement("iframe"); + ifr.src = "http://example.org/" + location.search.slice(1); + document.body.appendChild(ifr); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["*://example.org/"], + }, + files: { + "page.html": `<body><script src="page.js"></script></body>`, + "page.js": pageScript, + }, + }); + + await extension.startup(); + + // This page will load example.org in an iframe. + let url = `moz-extension://${extension.uuid}/page.html`; + let cookiesPromise = promiseLoadedCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + url + "?checkCookies", + { extension } + ); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's navigate. + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?navigate", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's run a fetch() + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?fetch", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's run a fetch() from a nested iframe (extension -> example.net -> + // example.org -> fetch) + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?nestedfetch", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a"); + await contentPage.close(); + + // Let's run a fetch() from a nested iframe (extension -> example.org -> fetch) + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage( + url + "?nestedfetch2", + { + extension, + } + ); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_cookies_iframes() { + server.registerPathHandler("/echocookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + request.hasHeader("Cookie") ? request.getHeader("Cookie") : "" + ); + }); + + server.registerPathHandler("/contentScriptHere", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html></html>"); + }); + + server.registerPathHandler("/pageWithFrames", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + + response.write(` + <html> + <iframe src="http://example.com/contentScriptHere"></iframe> + <iframe src="http://example.net/contentScriptHere"></iframe> + </html> + `); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["*://example.org/"], + content_scripts: [ + { + js: ["contentScript.js"], + matches: [ + "*://example.com/contentScriptHere", + "*://example.net/contentScriptHere", + ], + run_at: "document_end", + all_frames: true, + }, + ], + }, + files: { + "contentScript.js": async () => { + const res = await fetch("http://example.org/echocookies"); + const cookies = await res.text(); + browser.test.assertEq( + "none=a", + cookies, + "expected cookies in content script" + ); + browser.test.sendMessage("extfetch:" + location.hostname); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/pageWithFrames" + ); + await Promise.all([ + extension.awaitMessage("extfetch:example.com"), + extension.awaitMessage("extfetch:example.net"), + ]); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_cookies_background() { + async function background() { + const res = await fetch("http://example.org/echocookies", { + credentials: "include", + }); + const cookies = await res.text(); + browser.test.sendMessage("fetchcookies", cookies); + } + + const tests = [ + { + permissions: ["http://example.org/*"], + cookies: "none=a; lax=b; strict=c", + }, + { + permissions: [], + cookies: "none=a", + }, + ]; + + for (let test of tests) { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: test.permissions, + }, + }); + + server.registerPathHandler("/echocookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader( + "Access-Control-Allow-Origin", + `moz-extension://${extension.uuid}`, + false + ); + response.setHeader("Access-Control-Allow-Credentials", "true", false); + response.write( + request.hasHeader("Cookie") ? request.getHeader("Cookie") : "" + ); + }); + + await extension.startup(); + equal( + await extension.awaitMessage("fetchcookies"), + test.cookies, + "extension with permissions can see SameSite-restricted cookies" + ); + + await extension.unload(); + } +}); + +add_task(async function test_cookies_contentScript() { + server.registerPathHandler("/empty", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html><body></body></html>"); + }); + + async function contentScript() { + let res = await fetch("http://example.org/checkCookies"); + browser.test.assertEq(location.origin + "/ready", res.url, "request OK"); + browser.test.sendMessage("fetch-done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["contentscript.js"], + matches: ["*://*/*"], + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + }); + + await extension.startup(); + + let cookiesPromise = promiseLoadedCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/empty" + ); + await extension.awaitMessage("fetch-done"); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js new file mode 100644 index 0000000000..2847698340 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js @@ -0,0 +1,109 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.org"] }); +server.registerPathHandler("/sameSiteCookiesApiTest", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_samesite_cookies() { + function contentScript() { + document.cookie = "test1=whatever"; + document.cookie = "test2=whatever; SameSite=lax"; + document.cookie = "test3=whatever; SameSite=strict"; + browser.runtime.sendMessage("do-check-cookies"); + } + async function background() { + await new Promise(resolve => { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("do-check-cookies", msg, "expected message"); + resolve(); + }); + }); + + const url = "https://example.org/"; + + // Baseline. Every cookie must have the expected sameSite. + let cookie = await browser.cookies.get({ url, name: "test1" }); + browser.test.assertEq( + "no_restriction", + cookie.sameSite, + "Expected sameSite for test1" + ); + + cookie = await browser.cookies.get({ url, name: "test2" }); + browser.test.assertEq( + "lax", + cookie.sameSite, + "Expected sameSite for test2" + ); + + cookie = await browser.cookies.get({ url, name: "test3" }); + browser.test.assertEq( + "strict", + cookie.sameSite, + "Expected sameSite for test3" + ); + + // Testing cookies.getAll + cookies.set + let cookies = await browser.cookies.getAll({ url, name: "test3" }); + browser.test.assertEq(1, cookies.length, "There is only one test3 cookie"); + + cookie = await browser.cookies.set({ + url, + name: "test3", + value: "newvalue", + }); + browser.test.assertEq( + "no_restriction", + cookie.sameSite, + "sameSite defaults to no_restriction" + ); + + for (let sameSite of ["no_restriction", "lax", "strict"]) { + cookie = await browser.cookies.set({ url, name: "test3", sameSite }); + browser.test.assertEq( + sameSite, + cookie.sameSite, + `Expected sameSite=${sameSite} in return value of cookies.set` + ); + cookies = await browser.cookies.getAll({ url, name: "test3" }); + browser.test.assertEq( + 1, + cookies.length, + `test3 is still the only cookie after setting sameSite=${sameSite}` + ); + browser.test.assertEq( + sameSite, + cookies[0].sameSite, + `test3 was updated to sameSite=${sameSite}` + ); + } + + browser.test.notifyPass("cookies"); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://example.org/"], + content_scripts: [ + { + matches: ["*://example.org/sameSiteCookiesApiTest*"], + js: ["contentscript.js"], + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/sameSiteCookiesApiTest" + ); + await extension.awaitFinish("cookies"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js new file mode 100644 index 0000000000..a0a552f64f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js @@ -0,0 +1,316 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +add_task(async function testExtensionDebuggingUtilsCleanup() { + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + const expectedEmptyDebugUtils = { + hiddenXULWindow: null, + cacheSize: 0, + }; + + let { hiddenXULWindow, debugBrowserPromises } = ExtensionParent.DebugUtils; + + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "No ExtensionDebugUtils resources has been allocated yet" + ); + + await extension.startup(); + + await extension.awaitMessage("background.ready"); + + hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow; + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "No debugging resources has been yet allocated once the extension is running" + ); + + const fakeAddonActor = { + addonId: extension.id, + }; + + const anotherAddonActor = { + addonId: extension.id, + }; + + const waitFirstBrowser = ExtensionParent.DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + const waitSecondBrowser = ExtensionParent.DebugUtils.getExtensionProcessBrowser( + anotherAddonActor + ); + + const addonDebugBrowser = await waitFirstBrowser; + equal( + addonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + + equal( + await waitSecondBrowser, + addonDebugBrowser, + "Two addon debugging actors related to the same addon get the same browser element " + ); + + equal( + debugBrowserPromises.size, + 1, + "The expected resources has been allocated" + ); + + const nonExistentAddonActor = { + addonId: "non-existent-addon@test", + }; + + const waitRejection = ExtensionParent.DebugUtils.getExtensionProcessBrowser( + nonExistentAddonActor + ); + + await Assert.rejects( + waitRejection, + /Extension not found/, + "Reject with the expected message for non existent addons" + ); + + equal( + debugBrowserPromises.size, + 1, + "No additional debugging resources has been allocated" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + debugBrowserPromises.size, + 1, + "The addon debugging browser is cached until all the related actors have released it" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + anotherAddonActor + ); + + hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow; + + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "All the allocated debugging resources has been cleared" + ); + + await extension.unload(); +}); + +add_task(async function testExtensionDebuggingUtilsAddonReloaded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-reloaded@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + let fakeAddonActor = { + addonId: extension.id, + }; + + const addonDebugBrowser = await ExtensionParent.DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + equal( + addonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of requested debug browsers" + ); + + const { chromeDocument } = ExtensionParent.DebugUtils.hiddenXULWindow; + + ok( + addonDebugBrowser.parentElement === chromeDocument.documentElement, + "The addon debugging browser is part of the hiddenXULWindow chromeDocument" + ); + + await extension.unload(); + + // Install an extension with the same id to recreate for the DebugUtils + // conditions similar to an addon reloaded while the Addon Debugger is opened. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-reloaded@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of requested debug browsers" + ); + + const newAddonDebugBrowser = await ExtensionParent.DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + addonDebugBrowser, + newAddonDebugBrowser, + "The existent debugging browser has been reused" + ); + + equal( + newAddonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 0, + "All the addon debugging browsers has been released" + ); + + await extension.unload(); +}); + +add_task(async function testExtensionDebuggingUtilsWithMultipleAddons() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-addon-1@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + let anotherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-addon-2@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + await anotherExtension.startup(); + await anotherExtension.awaitMessage("background.ready"); + + const fakeAddonActor = { + addonId: extension.id, + }; + + const anotherFakeAddonActor = { + addonId: anotherExtension.id, + }; + + const { DebugUtils } = ExtensionParent; + const debugBrowser = await DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + const anotherDebugBrowser = await DebugUtils.getExtensionProcessBrowser( + anotherFakeAddonActor + ); + + const chromeDocument = DebugUtils.hiddenXULWindow.chromeDocument; + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 2, + "Got the expected number of debug browsers requested" + ); + ok( + debugBrowser.parentElement === chromeDocument.documentElement, + "The first debug browser is part of the hiddenXUL chromeDocument" + ); + ok( + anotherDebugBrowser.parentElement === chromeDocument.documentElement, + "The second debug browser is part of the hiddenXUL chromeDocument" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of debug browsers requested" + ); + + ok( + anotherDebugBrowser.parentElement === chromeDocument.documentElement, + "The second debug browser is still part of the hiddenXUL chromeDocument" + ); + + ok( + debugBrowser.parentElement == null, + "The first debug browser has been removed from the hiddenXUL chromeDocument" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + anotherFakeAddonActor + ); + + ok( + anotherDebugBrowser.parentElement == null, + "The second debug browser has been removed from the hiddenXUL chromeDocument" + ); + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 0, + "All the addon debugging browsers has been released" + ); + + await extension.unload(); + await anotherExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dns.js b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js new file mode 100644 index 0000000000..d7f9d6efe9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js @@ -0,0 +1,176 @@ +"use strict"; + +// Some test machines and android are not returning ipv6, turn it +// off to get consistent test results. +Services.prefs.setBoolPref("network.dns.disableIPv6", true); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +function getExtension(background = undefined) { + let manifest = { + permissions: ["dns", "proxy"], + }; + return ExtensionTestUtils.loadExtension({ + manifest, + background() { + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "proxy") { + await browser.proxy.settings.set({ value: data }); + browser.test.sendMessage("proxied"); + return; + } + browser.test.log(`=== dns resolve test ${JSON.stringify(data)}`); + browser.dns + .resolve(data.hostname, data.flags) + .then(result => { + browser.test.log( + `=== dns resolve result ${JSON.stringify(result)}` + ); + browser.test.sendMessage("resolved", result); + }) + .catch(e => { + browser.test.log(`=== dns resolve error ${e.message}`); + browser.test.sendMessage("resolved", { message: e.message }); + }); + }); + browser.test.sendMessage("ready"); + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }); +} + +const tests = [ + { + request: { + hostname: "localhost", + }, + expect: { + addresses: ["127.0.0.1"], // ipv6 disabled , "::1" + }, + }, + { + request: { + hostname: "localhost", + flags: ["offline"], + }, + expect: { + addresses: ["127.0.0.1"], // ipv6 disabled , "::1" + }, + }, + { + request: { + hostname: "test.example", + }, + expect: { + // android will error with offline + error: /NS_ERROR_UNKNOWN_HOST|NS_ERROR_OFFLINE/, + }, + }, + { + request: { + hostname: "127.0.0.1", + flags: ["canonical_name"], + }, + expect: { + canonicalName: "127.0.0.1", + addresses: ["127.0.0.1"], + }, + }, + { + request: { + hostname: "localhost", + flags: ["disable_ipv6"], + }, + expect: { + addresses: ["127.0.0.1"], + }, + }, +]; + +add_task(async function startup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_dns_resolve() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let test of tests) { + extension.sendMessage("resolve", test.request); + let result = await extension.awaitMessage("resolved"); + if (test.expect.error) { + ok( + test.expect.error.test(result.message), + `expected error ${result.message}` + ); + } else { + equal( + result.canonicalName, + test.expect.canonicalName, + "canonicalName match" + ); + // It seems there are platform differences happening that make this + // testing difficult. We're going to rely on other existing dns tests to validate + // the dns service itself works and only validate that we're getting generally + // expected results in the webext api. + ok( + result.addresses.length >= test.expect.addresses.length, + "expected number of addresses returned" + ); + if (test.expect.addresses.length && result.addresses.length) { + ok( + result.addresses.includes(test.expect.addresses[0]), + "got expected ip address" + ); + } + } + } + + await extension.unload(); +}); + +add_task(async function test_dns_resolve_socks() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("proxy", { + proxyType: "manual", + socks: "127.0.0.1", + socksVersion: 5, + proxyDNS: true, + }); + await extension.awaitMessage("proxied"); + equal( + Services.prefs.getIntPref("network.proxy.type"), + 1 /* PROXYCONFIG_MANUAL */, + "manual proxy" + ); + equal( + Services.prefs.getStringPref("network.proxy.socks"), + "127.0.0.1", + "socks proxy" + ); + ok( + Services.prefs.getBoolPref("network.proxy.socks_remote_dns"), + "socks remote dns" + ); + extension.sendMessage("resolve", { + hostname: "mozilla.org", + }); + let result = await extension.awaitMessage("resolved"); + ok( + /NS_ERROR_UNKNOWN_PROXY_HOST/.test(result.message), + `expected error ${result.message}` + ); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js new file mode 100644 index 0000000000..f65df707e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js @@ -0,0 +1,38 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_downloads_api_namespace_and_permissions() { + function backgroundScript() { + browser.test.assertTrue(!!browser.downloads, "`downloads` API is present."); + browser.test.assertTrue( + !!browser.downloads.FilenameConflictAction, + "`downloads.FilenameConflictAction` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.InterruptReason, + "`downloads.InterruptReason` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.DangerType, + "`downloads.DangerType` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.State, + "`downloads.State` enum is present." + ); + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads", "downloads.open"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js new file mode 100644 index 0000000000..aa91cd7c88 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js @@ -0,0 +1,216 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { UrlClassifierTestUtils } = ChromeUtils.import( + "resource://testing-common/UrlClassifierTestUtils.jsm" +); + +// Value for network.cookie.cookieBehavior to reject all third-party cookies. +const { BEHAVIOR_REJECT_FOREIGN } = Ci.nsICookieService; + +const server = createHttpServer({ hosts: ["example.net", "itisatracker.org"] }); +server.registerPathHandler("/setcookies", (request, response) => { + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Set-Cookie", "c_none=1; sameSite=none", true); + response.setHeader("Set-Cookie", "c_lax=1; sameSite=lax", true); + response.setHeader("Set-Cookie", "c_strict=1; sameSite=strict", true); +}); + +server.registerPathHandler("/download", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + + let cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + // Assign the result through the MIME-type, to make it easier to read the + // result via the downloads API. + response.setHeader("Content-Type", `dummy/${encodeURIComponent(cookies)}`); + // Response of length 7. + response.write("1234567"); +}); + +server.registerPathHandler("/redirect", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Found"); + response.setHeader("Location", "/download"); +}); + +function createDownloadTestExtension(extraPermissions = []) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads", ...extraPermissions], + }, + incognitoOverride: "spanning", + background() { + async function getCookiesForDownload(url) { + let donePromise = new Promise(resolve => { + browser.downloads.onChanged.addListener(async delta => { + if (delta.state?.current === "complete") { + resolve(delta.id); + } + }); + }); + // TODO bug 1653636: Remove this when the correct browsing mode is used. + const incognito = browser.extension.inIncognitoContext; + let downloadId = await browser.downloads.download({ url, incognito }); + browser.test.assertEq(await donePromise, downloadId, "got download"); + let [download] = await browser.downloads.search({ id: downloadId }); + browser.test.log(`Download results: ${JSON.stringify(download)}`); + + // Delete the file since we aren't interested in it. + // TODO bug 1654819: On Windows the file may be recreated. + await browser.downloads.removeFile(download.id); + // Sanity check to verify that we got the result from /download. + browser.test.assertEq(7, download.fileSize, "download succeeded"); + + // The "/download" endpoint mirrors received cookies via Content-Type. + let cookies = decodeURIComponent(download.mime.replace("dummy/", "")); + return cookies; + } + + browser.test.onMessage.addListener(async url => { + browser.test.sendMessage("result", await getCookiesForDownload(url)); + }); + }, + }); +} + +async function downloadAndGetCookies(extension, url) { + extension.sendMessage(url); + return extension.awaitMessage("result"); +} + +add_task(async function setup() { + const nsIFile = Ci.nsIFile; + const downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + // Support sameSite=none despite the server using http instead of https. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); + async function loadAndClose(url) { + let contentPage = await ExtensionTestUtils.loadContentPage(url); + await contentPage.close(); + } + // Generate cookies for use in this test. + await loadAndClose("http://example.net/setcookies"); + await loadAndClose("http://itisatracker.org/setcookies"); + + await UrlClassifierTestUtils.addTestTrackers(); + registerCleanupFunction(() => { + UrlClassifierTestUtils.cleanupTestTrackers(); + Services.cookies.removeAll(); + + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + downloadDir.remove(false); + }); +}); + +// Checks that (sameSite) cookies are included in download requests. +add_task(async function download_cookies_basic() { + let extension = createDownloadTestExtension(["*://example.net/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with sameSite cookies" + ); + + equal( + await downloadAndGetCookies(extension, "http://example.net/redirect"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with redirect" + ); + + await runWithPrefs( + [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]], + async () => { + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with all third-party cookies disabled" + ); + } + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies are included even when tracking protection +// would block cookies from third-party requests. +add_task(async function download_cookies_from_tracker_url() { + let extension = createDownloadTestExtension(["*://itisatracker.org/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://itisatracker.org/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download of itisatracker.org" + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies are included even without host permissions. +add_task(async function download_cookies_without_host_permissions() { + let extension = createDownloadTestExtension(); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download without host permissions" + ); + + equal( + await downloadAndGetCookies(extension, "http://itisatracker.org/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download of itisatracker.org" + ); + + await runWithPrefs( + [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]], + async () => { + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with all third-party cookies disabled" + ); + } + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies from private browsing are included. +add_task(async function download_cookies_in_perma_private_browsing() { + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + let extension = createDownloadTestExtension(["*://example.net/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "", + "Initially no cookies in permanent private browsing mode" + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.net/setcookies", + { privateBrowsing: true } + ); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download in perma-private-browsing mode" + ); + + await extension.unload(); + await contentPage.close(); + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js new file mode 100644 index 0000000000..a9edb9d13e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js @@ -0,0 +1,680 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Downloads } = ChromeUtils.import( + "resource://gre/modules/Downloads.jsm" +); + +const gServer = createHttpServer(); +gServer.registerDirectory("/data/", do_get_file("data")); + +gServer.registerPathHandler("/dir/", (_, res) => res.write("length=8")); + +const WINDOWS = AppConstants.platform == "win"; + +const BASE = `http://localhost:${gServer.identity.primaryPort}/`; +const FILE_NAME = "file_download.txt"; +const FILE_NAME_W_SPACES = "file download.txt"; +const FILE_URL = BASE + "data/" + FILE_NAME; +const FILE_NAME_UNIQUE = "file_download(1).txt"; +const FILE_LEN = 46; + +let downloadDir; + +function setup() { + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +} + +function backgroundScript() { + let blobUrl; + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + let options = args[0]; + + if (options.blobme) { + let blob = new Blob(options.blobme); + delete options.blobme; + blobUrl = options.url = window.URL.createObjectURL(blob); + } + + try { + let id = await browser.downloads.download(options); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "killTheBlob") { + window.URL.revokeObjectURL(blobUrl); + blobUrl = null; + } + }); + + browser.test.sendMessage("ready"); +} + +// This function is a bit of a sledgehammer, it looks at every download +// the browser knows about and waits for all active downloads to complete. +// But we only start one at a time and only do a handful in total, so +// this lets us test download() without depending on anything else. +async function waitForDownloads() { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + let inprogress = downloads.filter(dl => !dl.stopped); + return Promise.all(inprogress.map(dl => dl.whenSucceeded())); +} + +// Create a file in the downloads directory. +function touch(filename) { + let file = downloadDir.clone(); + file.append(filename); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); +} + +// Remove a file in the downloads directory. +function remove(filename, recursive = false) { + let file = downloadDir.clone(); + file.append(filename); + file.remove(recursive); +} + +add_task(async function test_downloads() { + setup(); + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + function download(options) { + extension.sendMessage("download.request", options); + return extension.awaitMessage("download.done"); + } + + async function testDownload(options, localFile, expectedSize, description) { + let msg = await download(options); + equal( + msg.status, + "success", + `downloads.download() works with ${description}` + ); + + await waitForDownloads(); + + let localPath = downloadDir.clone(); + let parts = Array.isArray(localFile) ? localFile : [localFile]; + + parts.map(p => localPath.append(p)); + equal( + localPath.fileSize, + expectedSize, + "Downloaded file has expected size" + ); + localPath.remove(false); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + info("extension started"); + + // Call download() with just the url property. + await testDownload({ url: FILE_URL }, FILE_NAME, FILE_LEN, "just source"); + + // Call download() with a filename property. + await testDownload( + { + url: FILE_URL, + filename: "newpath.txt", + }, + "newpath.txt", + FILE_LEN, + "source and filename" + ); + + // Call download() with a filename with subdirs. + await testDownload( + { + url: FILE_URL, + filename: "sub/dir/file", + }, + ["sub", "dir", "file"], + FILE_LEN, + "source and filename with subdirs" + ); + + // Call download() with a filename with existing subdirs. + await testDownload( + { + url: FILE_URL, + filename: "sub/dir/file2", + }, + ["sub", "dir", "file2"], + FILE_LEN, + "source and filename with existing subdirs" + ); + + // Only run Windows path separator test on Windows. + if (WINDOWS) { + // Call download() with a filename with Windows path separator. + await testDownload( + { + url: FILE_URL, + filename: "sub\\dir\\file3", + }, + ["sub", "dir", "file3"], + FILE_LEN, + "filename with Windows path separator" + ); + } + remove("sub", true); + + // Call download(), filename with subdir, skipping parts. + await testDownload( + { + url: FILE_URL, + filename: "skip//part", + }, + ["skip", "part"], + FILE_LEN, + "source, filename, with subdir, skipping parts" + ); + remove("skip", true); + + // Check conflictAction of "uniquify". + touch(FILE_NAME); + await testDownload( + { + url: FILE_URL, + conflictAction: "uniquify", + }, + FILE_NAME_UNIQUE, + FILE_LEN, + "conflictAction=uniquify" + ); + // todo check that preexisting file was not modified? + remove(FILE_NAME); + + // Check conflictAction of "overwrite". + touch(FILE_NAME); + await testDownload( + { + url: FILE_URL, + conflictAction: "overwrite", + }, + FILE_NAME, + FILE_LEN, + "conflictAction=overwrite" + ); + + // Try to download in invalid url + await download({ url: "this is not a valid URL" }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with invalid url"); + ok( + /not a valid URL/.test(msg.errmsg), + "error message for invalid url is correct" + ); + }); + + // Try to download to an empty path. + await download({ + url: FILE_URL, + filename: "", + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with empty filename" + ); + equal( + msg.errmsg, + "filename must not be empty", + "error message for empty filename is correct" + ); + }); + + // Try to download to an absolute path. + const absolutePath = OS.Path.join( + WINDOWS ? "\\tmp" : "/tmp", + "file_download.txt" + ); + await download({ + url: FILE_URL, + filename: absolutePath, + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with absolute filename" + ); + equal( + msg.errmsg, + "filename must not be an absolute path", + `error message for absolute path (${absolutePath}) is correct` + ); + }); + + if (WINDOWS) { + await download({ + url: FILE_URL, + filename: "C:\\file_download.txt", + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with absolute filename" + ); + equal( + msg.errmsg, + "filename must not be an absolute path", + "error message for absolute path with drive letter is correct" + ); + }); + } + + // Try to download to a relative path containing .. + await download({ + url: FILE_URL, + filename: OS.Path.join("..", "file_download.txt"), + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with back-references" + ); + equal( + msg.errmsg, + "filename must not contain back-references (..)", + "error message for back-references is correct" + ); + }); + + // Try to download to a long relative path containing .. + await download({ + url: FILE_URL, + filename: OS.Path.join("foo", "..", "..", "file_download.txt"), + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with back-references" + ); + equal( + msg.errmsg, + "filename must not contain back-references (..)", + "error message for back-references is correct" + ); + }); + + // Test illegal characters. + await download({ + url: FILE_URL, + filename: "like:this", + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with illegal chars"); + equal( + msg.errmsg, + "filename must not contain illegal characters", + "error message correct" + ); + }); + + // Try to download a blob url + const BLOB_STRING = "Hello, world"; + await testDownload( + { + blobme: [BLOB_STRING], + filename: FILE_NAME, + }, + FILE_NAME, + BLOB_STRING.length, + "blob url" + ); + extension.sendMessage("killTheBlob"); + + // Try to download a blob url without a given filename + await testDownload( + { + blobme: [BLOB_STRING], + }, + "download", + BLOB_STRING.length, + "blob url with no filename" + ); + extension.sendMessage("killTheBlob"); + + // Download a normal URL with an empty filename part. + await testDownload( + { + url: BASE + "dir/", + }, + "download", + 8, + "normal url with empty filename" + ); + + // Download a filename with multiple spaces, url is ignored for this test. + await testDownload( + { + url: FILE_URL, + filename: "a file.txt", + }, + "a file.txt", + FILE_LEN, + "filename with multiple spaces" + ); + + // Download a normal URL with a leafname containing multiple spaces. + // Note: spaces are compressed by file name normalization. + await testDownload( + { + url: BASE + "data/" + FILE_NAME_W_SPACES, + }, + FILE_NAME_W_SPACES.replace(/\s+/, " "), + FILE_LEN, + "leafname with multiple spaces" + ); + + // Check that the "incognito" property is supported. + await testDownload( + { + url: FILE_URL, + incognito: false, + }, + FILE_NAME, + FILE_LEN, + "incognito=false" + ); + + await testDownload( + { + url: FILE_URL, + incognito: true, + }, + FILE_NAME, + FILE_LEN, + "incognito=true" + ); + + await extension.unload(); +}); + +async function testHttpErrors(allowHttpErrors) { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/error`; + const content = "HTTP Error test"; + + server.registerPathHandler("/error", (request, response) => { + response.setStatusLine( + "1.1", + parseInt(request.queryString, 10), + "Some Error" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Content-Length", content.length.toString()); + response.write(content); + }); + + function background(code) { + let dlid = 0; + let expectedState; + browser.test.onMessage.addListener(async options => { + try { + expectedState = options.allowHttpErrors ? "complete" : "interrupted"; + dlid = await browser.downloads.download(options); + } catch (err) { + browser.test.fail(`Unexpected error in downloads.download(): ${err}`); + } + }); + function onChanged({ id, state }) { + if (dlid !== id || !state || state.current === "in_progress") { + return; + } + browser.test.assertEq(state.current, expectedState, "correct state"); + browser.downloads.search({ id }).then(([download]) => { + browser.test.sendMessage("done", download.error); + }); + } + browser.downloads.onChanged.addListener(onChanged); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background, + }); + await extension.startup(); + + async function download(code, expected_when_disallowed) { + const options = { + url: url + "?" + code, + filename: `test-${code}`, + conflictAction: "overwrite", + allowHttpErrors, + }; + extension.sendMessage(options); + const rv = await extension.awaitMessage("done"); + + if (allowHttpErrors) { + const localPath = downloadDir.clone(); + localPath.append(options.filename); + equal( + localPath.fileSize, + // The 20x No content errors will not produce any response body, + // only "true" errors do. + code >= 400 ? content.length : 0, + "Downloaded file has expected size" + code + ); + localPath.remove(false); + + ok(!rv, "error must be ignored and hence false-y"); + return; + } + + equal( + rv, + expected_when_disallowed, + "error must have the correct InterruptReason" + ); + } + + await download(204, "SERVER_BAD_CONTENT"); // No Content + await download(205, "SERVER_BAD_CONTENT"); // Reset Content + await download(404, "SERVER_BAD_CONTENT"); // Not Found + await download(403, "SERVER_FORBIDDEN"); // Forbidden + await download(402, "SERVER_UNAUTHORIZED"); // Unauthorized + await download(407, "SERVER_UNAUTHORIZED"); // Proxy auth required + await download(504, "SERVER_FAILED"); //General errors, here Gateway Timeout + + await extension.unload(); +} + +add_task(function test_download_disallowed_http_errors() { + return testHttpErrors(false); +}); + +add_task(function test_download_allowed_http_errors() { + return testHttpErrors(true); +}); + +add_task(async function test_download_http_details() { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/post-log`; + + let received; + server.registerPathHandler("/post-log", (request, response) => { + received = request; + response.setHeader("Set-Cookie", "monster=", false); + }); + + // Confirm received vs. expected values. + function confirm(method, headers = {}, body) { + equal(received.method, method, "method is correct"); + + for (let name in headers) { + ok(received.hasHeader(name), `header ${name} received`); + equal( + received.getHeader(name), + headers[name], + `header ${name} is correct` + ); + } + + if (body) { + const str = NetUtil.readInputStreamToString( + received.bodyInputStream, + received.bodyInputStream.available() + ); + equal(str, body, "body is correct"); + } + } + + function background() { + browser.test.onMessage.addListener(async options => { + try { + await browser.downloads.download(options); + } catch (err) { + browser.test.sendMessage("done", { err: err.message }); + } + }); + browser.downloads.onChanged.addListener(({ state }) => { + if (state && state.current === "complete") { + browser.test.sendMessage("done", { ok: true }); + } + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background, + incognitoOverride: "spanning", + }); + await extension.startup(); + + function download(options) { + options.url = url; + options.conflictAction = "overwrite"; + + extension.sendMessage(options); + return extension.awaitMessage("done"); + } + + // Test that site cookies are sent with download requests, + // and "incognito" downloads use a separate cookie jar. + let testDownloadCookie = async function(incognito) { + let result = await download({ incognito }); + ok(result.ok, `preflight to set cookies with incognito=${incognito}`); + ok(!received.hasHeader("cookie"), "first request has no cookies"); + + result = await download({ incognito }); + ok(result.ok, `download with cookie with incognito=${incognito}`); + equal( + received.getHeader("cookie"), + "monster=", + "correct cookie header sent for second download" + ); + }; + + await testDownloadCookie(false); + await testDownloadCookie(true); + + // Test method option. + let result = await download({}); + ok(result.ok, "download works without the method option, defaults to GET"); + confirm("GET"); + + result = await download({ method: "PUT" }); + ok(!result.ok, "download rejected with PUT method"); + ok( + /method: Invalid enumeration/.test(result.err), + "descriptive error message" + ); + + result = await download({ method: "POST" }); + ok(result.ok, "download works with POST method"); + confirm("POST"); + + // Test body option values. + result = await download({ body: [] }); + ok(!result.ok, "download rejected because of non-string body"); + ok(/body: Expected string/.test(result.err), "descriptive error message"); + + result = await download({ method: "POST", body: "of work" }); + ok(result.ok, "download works with POST method and body"); + confirm("POST", { "Content-Length": 7 }, "of work"); + + // Test custom headers. + result = await download({ headers: [{ name: "X-Custom" }] }); + ok(!result.ok, "download rejected because of missing header value"); + ok(/"value" is required/.test(result.err), "descriptive error message"); + + result = await download({ headers: [{ name: "X-Custom", value: "13" }] }); + ok(result.ok, "download works with a custom header"); + confirm("GET", { "X-Custom": "13" }); + + // Test Referer header. + const referer = "http://example.org/test"; + result = await download({ headers: [{ name: "Referer", value: referer }] }); + ok(result.ok, "download works with Referer header"); + confirm("GET", { Referer: referer }); + + // Test forbidden headers. + result = await download({ headers: [{ name: "DNT", value: "1" }] }); + ok(!result.ok, "download rejected because of forbidden header name DNT"); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({ + headers: [{ name: "Proxy-Connection", value: "keep" }], + }); + ok( + !result.ok, + "download rejected because of forbidden header name prefix Proxy-" + ); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({ headers: [{ name: "Sec-ret", value: "13" }] }); + ok( + !result.ok, + "download rejected because of forbidden header name prefix Sec-" + ); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + remove("post-log"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js new file mode 100644 index 0000000000..9de40a8c9c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js @@ -0,0 +1,1069 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.import( + "resource://gre/modules/Downloads.jsm" +); + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const ROOT = `http://localhost:${server.identity.primaryPort}`; +const BASE = `${ROOT}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; + +// Keep these in sync with code in interruptible.sjs +const INT_PARTIAL_LEN = 15; +const INT_TOTAL_LEN = 31; + +const TEST_DATA = "This is 31 bytes of sample data"; +const TOTAL_LEN = TEST_DATA.length; +const PARTIAL_LEN = 15; + +// A handler to let us systematically test pausing/resuming/canceling +// of downloads. This target represents a small text file but a simple +// GET will stall after sending part of the data, to give the test code +// a chance to pause or do other operations on an in-progress download. +// A resumed download (ie, a GET with a Range: header) will allow the +// download to complete. +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + + if (request.hasHeader("Range")) { + let start, end; + let matches = request + .getHeader("Range") + .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/); + if (matches != null) { + start = matches[1] ? parseInt(matches[1], 10) : 0; + end = matches[2] ? parseInt(matches[2], 10) : TOTAL_LEN - 1; + } + + if (end == undefined || end >= TOTAL_LEN) { + response.setStatusLine( + request.httpVersion, + 416, + "Requested Range Not Satisfiable" + ); + response.setHeader("Content-Range", `*/${TOTAL_LEN}`, false); + response.finish(); + return; + } + + response.setStatusLine(request.httpVersion, 206, "Partial Content"); + response.setHeader("Content-Range", `${start}-${end}/${TOTAL_LEN}`, false); + response.write(TEST_DATA.slice(start, end + 1)); + } else if (request.queryString.includes("stream")) { + response.processAsync(); + response.setHeader("Content-Length", "10000", false); + response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + setInterval(() => { + response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + }, 50); + } else { + response.processAsync(); + response.setHeader("Content-Length", `${TOTAL_LEN}`, false); + response.write(TEST_DATA.slice(0, PARTIAL_LEN)); + } + + registerCleanupFunction(() => { + try { + response.finish(); + } catch (e) { + // This will throw, but we don't care at this point. + } + }); +} + +server.registerPrefixHandler("/interruptible/", handleRequest); + +let interruptibleCount = 0; +function getInterruptibleUrl(filename = "interruptible.html") { + let n = interruptibleCount++; + return `${ROOT}/interruptible/${filename}?count=${n}`; +} + +function backgroundScript() { + let events = new Set(); + let eventWaiter = null; + + browser.downloads.onCreated.addListener(data => { + events.add({ type: "onCreated", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + browser.downloads.onChanged.addListener(data => { + events.add({ type: "onChanged", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + browser.downloads.onErased.addListener(data => { + events.add({ type: "onErased", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + // Returns a promise that will resolve when the given list of expected + // events have all been seen. By default, succeeds only if the exact list + // of expected events is seen in the given order. options.exact can be + // set to false to allow other events and options.inorder can be set to + // false to allow the events to arrive in any order. + function waitForEvents(expected, options = {}) { + function compare(a, b) { + if (typeof b == "object" && b != null) { + if (typeof a != "object") { + return false; + } + return Object.keys(b).every(fld => compare(a[fld], b[fld])); + } + return a == b; + } + + const exact = "exact" in options ? options.exact : true; + const inorder = "inorder" in options ? options.inorder : true; + return new Promise((resolve, reject) => { + function check() { + function fail(msg) { + browser.test.fail(msg); + reject(new Error(msg)); + } + if (events.size < expected.length) { + return; + } + if (exact && expected.length < events.size) { + fail( + `Got ${events.size} events but only expected ${expected.length}` + ); + return; + } + + let remaining = new Set(events); + if (inorder) { + for (let event of events) { + if (compare(event, expected[0])) { + expected.shift(); + remaining.delete(event); + } + } + } else { + expected = expected.filter(val => { + for (let remainingEvent of remaining) { + if (compare(remainingEvent, val)) { + remaining.delete(remainingEvent); + return false; + } + } + return true; + }); + } + + // Events that did occur have been removed from expected so if + // expected is empty, we're done. If we didn't see all the + // expected events and we're not looking for an exact match, + // then we just may not have seen the event yet, so return without + // failing and check() will be called again when a new event arrives. + if (!expected.length) { + events = remaining; + eventWaiter = null; + resolve(); + } else if (exact) { + fail( + `Mismatched event: expecting ${JSON.stringify( + expected[0] + )} but got ${JSON.stringify(Array.from(remaining)[0])}` + ); + } + } + eventWaiter = check; + check(); + }); + } + + browser.test.onMessage.addListener(async (msg, ...args) => { + let match = msg.match(/(\w+).request$/); + if (!match) { + return; + } + + let what = match[1]; + if (what == "waitForEvents") { + try { + await waitForEvents(...args); + browser.test.sendMessage("waitForEvents.done", { status: "success" }); + } catch (error) { + browser.test.sendMessage("waitForEvents.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (what == "clearEvents") { + events = new Set(); + browser.test.sendMessage("clearEvents.done", { status: "success" }); + } else { + try { + let result = await browser.downloads[what](...args); + browser.test.sendMessage(`${what}.done`, { status: "success", result }); + } catch (error) { + browser.test.sendMessage(`${what}.done`, { + status: "error", + errmsg: error.message, + }); + } + } + }); + + browser.test.sendMessage("ready"); +} + +let downloadDir; +let extension; + +async function waitForCreatedPartFile(baseFilename = "interruptible.html") { + const partFilePath = `${downloadDir.path}/${baseFilename}.part`; + + info(`Wait for ${partFilePath} to be created`); + let lastError; + await TestUtils.waitForCondition( + async () => + OS.File.stat(partFilePath).then( + () => true, + err => { + lastError = err; + return false; + } + ), + `Wait for the ${partFilePath} to exists before pausing the download` + ).catch(err => { + if (lastError) { + throw lastError; + } + throw err; + }); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +function runInExtension(what, ...args) { + extension.sendMessage(`${what}.request`, ...args); + return extension.awaitMessage(`${what}.done`); +} + +// This is pretty simplistic, it looks for a progress update for a +// download of the given url in which the total bytes are exactly equal +// to the given value. Unless you know exactly how data will arrive from +// the server (eg see interruptible.sjs), it probably isn't very useful. +async function waitForProgress(url, testFn) { + let list = await Downloads.getList(Downloads.ALL); + + return new Promise(resolve => { + const view = { + onDownloadChanged(download) { + if (download.source.url == url && testFn(download.currentBytes)) { + list.removeView(view); + resolve(download.currentBytes); + } + }, + }; + list.addView(view); + }); +} + +add_task(async function setup() { + const nsIFile = Ci.nsIFile; + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + downloadDir.remove(true); + + return clearDownloads(); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); +}); + +add_task(async function test_events() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onCreated and onChanged events"); +}); + +add_task(async function test_cancel() { + let url = getInterruptibleUrl(); + info(url); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("cancel", id); + equal(msg.status, "success", "cancel() succeeded"); + + // TODO bug 1256243: This sequence of events is bogus + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + { + type: "onChanged", + data: { + id, + paused: { + previous: true, + current: false, + }, + }, + }, + ]); + equal( + msg.status, + "success", + "got onChanged events corresponding to cancel()" + ); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause a canceled download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "error", "cannot resume a canceled download"); +}); + +add_task(async function test_pauseresume() { + const filename = "pauseresume.html"; + let url = getInterruptibleUrl(filename); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + // Prevent intermittent timeouts due to the part file not yet created + // (e.g. see Bug 1573360). + await waitForCreatedPartFile(filename); + + info("Pause the download item"); + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("search", { paused: true }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, true, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, true, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_PARTIAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + let found = msg.result.filter(item => item.id == id); + equal(found.length, 1, "search() by error found the paused download"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause an already paused download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "success", "resume() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "interrupted", + current: "in_progress", + }, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + error: { + previous: "USER_CANCELED", + current: null, + }, + }, + }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events for resume and complete"); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].state, "complete", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, null, "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_TOTAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, true, "download.exists is correct"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause a completed download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "error", "cannot resume a completed download"); +}); + +add_task(async function test_pausecancel() { + let url = getInterruptibleUrl(); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("search", { paused: true }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, true, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, true, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_PARTIAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + let found = msg.result.filter(item => item.id == id); + equal(found.length, 1, "search() by error found the paused download"); + + msg = await runInExtension("cancel", id); + equal(msg.status, "success", "cancel() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event for cancel"); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); +}); + +add_task(async function test_pause_resume_cancel_badargs() { + let BAD_ID = 1000; + + let msg = await runInExtension("pause", BAD_ID); + equal(msg.status, "error", "pause() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); + + msg = await runInExtension("resume", BAD_ID); + equal(msg.status, "error", "resume() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); + + msg = await runInExtension("cancel", BAD_ID); + equal(msg.status, "error", "cancel() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); +}); + +add_task(async function test_file_removal() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + + equal(msg.status, "success", "got onCreated and onChanged events"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "success", "removeFile() succeeded"); + + msg = await runInExtension("removeFile", id); + equal( + msg.status, + "error", + "removeFile() fails since the file was already removed." + ); + ok( + /file doesn't exist/.test(msg.errmsg), + "removeFile() failed on removed file." + ); + + msg = await runInExtension("removeFile", 1000); + ok( + /Invalid download id/.test(msg.errmsg), + "removeFile() failed due to non-existent id" + ); +}); + +add_task(async function test_removal_of_incomplete_download() { + const filename = "remove-incomplete.html"; + let url = getInterruptibleUrl(filename); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + // Prevent intermittent timeouts due to the part file not yet created + // (e.g. see Bug 1573360). + await waitForCreatedPartFile(filename); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "error", "removeFile() on paused download failed"); + + ok( + /Cannot remove incomplete download/.test(msg.errmsg), + "removeFile() failed due to download being incomplete" + ); + + msg = await runInExtension("resume", id); + equal(msg.status, "success", "resume() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "interrupted", + current: "in_progress", + }, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + error: { + previous: "USER_CANCELED", + current: null, + }, + }, + }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events for resume and complete"); + + msg = await runInExtension("removeFile", id); + equal( + msg.status, + "success", + "removeFile() succeeded following completion of resumed download." + ); +}); + +// Test erase(). We don't do elaborate testing of the query handling +// since it uses the exact same engine as search() which is tested +// more thoroughly in test_chrome_ext_downloads_search.html +add_task(async function test_erase() { + await clearDownloads(); + + await runInExtension("clearEvents"); + + async function download() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download succeeded"); + let id = msg.result; + + msg = await runInExtension( + "waitForEvents", + [ + { + type: "onChanged", + data: { id, state: { current: "complete" } }, + }, + ], + { exact: false } + ); + equal(msg.status, "success", "download finished"); + + return id; + } + + let ids = {}; + ids.dl1 = await download(); + ids.dl2 = await download(); + ids.dl3 = await download(); + + let msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 3, "search found 3 downloads"); + + msg = await runInExtension("clearEvents"); + + msg = await runInExtension("erase", { id: ids.dl1 }); + equal(msg.status, "success", "erase by id succeeded"); + + msg = await runInExtension("waitForEvents", [ + { type: "onErased", data: ids.dl1 }, + ]); + equal(msg.status, "success", "received onErased event"); + + msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 2, "search found 2 downloads"); + + msg = await runInExtension("erase", {}); + equal(msg.status, "success", "erase everything succeeded"); + + msg = await runInExtension( + "waitForEvents", + [ + { type: "onErased", data: ids.dl2 }, + { type: "onErased", data: ids.dl3 }, + ], + { inorder: false } + ); + equal(msg.status, "success", "received 2 onErased events"); + + msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 0, "search found 0 downloads"); +}); + +function loadImage(img, data) { + return new Promise(resolve => { + img.src = data; + img.onload = resolve; + }); +} + +add_task(async function test_getFileIcon() { + let webNav = Services.appShell.createWindowlessBrowser(false); + let docShell = webNav.docShell; + + let system = Services.scriptSecurityManager.getSystemPrincipal(); + docShell.createAboutBlankContentViewer(system, system); + + let img = webNav.document.createElement("img"); + + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("getFileIcon", id); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 32, "returns an icon with the right height"); + equal(img.width, 32, "returns an icon with the right width"); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { type: "onChanged" }, + ]); + equal(msg.status, "success", "got events"); + + msg = await runInExtension("getFileIcon", id); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 32, "returns an icon with the right height after download"); + equal(img.width, 32, "returns an icon with the right width after download"); + + msg = await runInExtension("getFileIcon", id + 100); + equal(msg.status, "error", "getFileIcon() failed"); + ok(msg.errmsg.includes("Invalid download id"), "download id is invalid"); + + msg = await runInExtension("getFileIcon", id, { size: 127 }); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 127, "returns an icon with the right custom height"); + equal(img.width, 127, "returns an icon with the right custom width"); + + msg = await runInExtension("getFileIcon", id, { size: 1 }); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 1, "returns an icon with the right custom height"); + equal(img.width, 1, "returns an icon with the right custom width"); + + msg = await runInExtension("getFileIcon", id, { size: "foo" }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is not a number"); + + msg = await runInExtension("getFileIcon", id, { size: 0 }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is too small"); + + msg = await runInExtension("getFileIcon", id, { size: 128 }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is too big"); + + webNav.close(); +}); + +add_task(async function test_estimatedendtime() { + // Note we are not testing the actual value calculation of estimatedEndTime, + // only whether it is null/non-null at the appropriate times. + + let url = `${getInterruptibleUrl()}&stream=1`; + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let previousBytes = await waitForProgress(url, bytes => bytes > 0); + await waitForProgress(url, bytes => bytes > previousBytes); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + ok(msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct"); + ok(msg.result[0].bytesReceived > 0, "download.bytesReceived is correct"); + + msg = await runInExtension("cancel", id); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + ok(!msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct"); +}); + +add_task(async function test_byExtension() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + msg = await runInExtension("search", { id }); + + equal(msg.result.length, 1, "search() found 1 download"); + equal( + msg.result[0].byExtensionName, + "Generated extension", + "download.byExtensionName is correct" + ); + equal( + msg.result[0].byExtensionId, + extension.id, + "download.byExtensionId is correct" + ); +}); + +add_task(async function cleanup() { + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js new file mode 100644 index 0000000000..b80e5f3274 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js @@ -0,0 +1,308 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; + +add_task(function setup() { + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +}); + +add_task(async function test_private_download() { + let pb_extension = ExtensionTestUtils.loadExtension({ + background: async function() { + function promiseEvent(eventTarget, accept) { + return new Promise(resolve => { + eventTarget.addListener(function listener(data) { + if (accept && !accept(data)) { + return; + } + eventTarget.removeListener(listener); + resolve(data); + }); + }); + } + let startTestPromise = promiseEvent(browser.test.onMessage); + let removeTestPromise = promiseEvent( + browser.test.onMessage, + msg => msg == "remove" + ); + let onCreatedPromise = promiseEvent(browser.downloads.onCreated); + let onDonePromise = promiseEvent( + browser.downloads.onChanged, + delta => delta.state && delta.state.current === "complete" + ); + + browser.test.sendMessage("ready"); + let { url, filename } = await startTestPromise; + + browser.test.log("Starting private download"); + let downloadId = await browser.downloads.download({ + url, + filename, + incognito: true, + }); + browser.test.sendMessage("downloadId", downloadId); + + browser.test.log("Waiting for downloads.onCreated"); + let createdItem = await onCreatedPromise; + + browser.test.log("Waiting for completion notification"); + await onDonePromise; + + // test_ext_downloads_download.js already tests whether the file exists + // in the file system. Here we will only verify that the downloads API + // behaves in a meaningful way. + + let [downloadItem] = await browser.downloads.search({ id: downloadId }); + browser.test.assertEq(url, createdItem.url, "onCreated url should match"); + browser.test.assertEq(url, downloadItem.url, "download url should match"); + browser.test.assertTrue( + createdItem.incognito, + "created download should be private" + ); + browser.test.assertTrue( + downloadItem.incognito, + "stored download should be private" + ); + + await removeTestPromise; + browser.test.log("Removing downloaded file"); + browser.test.assertTrue(downloadItem.exists, "downloaded file exists"); + await browser.downloads.removeFile(downloadId); + + // Disabled because the assertion fails - https://bugzil.la/1381031 + // let [downloadItem2] = await browser.downloads.search({id: downloadId}); + // browser.test.assertFalse(downloadItem2.exists, "file should be deleted"); + + browser.test.log("Erasing private download from history"); + let erasePromise = promiseEvent(browser.downloads.onErased); + await browser.downloads.erase({ id: downloadId }); + browser.test.assertEq( + downloadId, + await erasePromise, + "onErased should be fired for the erased private download" + ); + + browser.test.notifyPass("private download test done"); + }, + manifest: { + applications: { gecko: { id: "@spanning" } }, + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "@not_allowed" } }, + permissions: ["downloads", "downloads.open"], + }, + background: async function() { + browser.downloads.onCreated.addListener(() => { + browser.test.fail("download-onCreated"); + }); + browser.downloads.onChanged.addListener(() => { + browser.test.fail("download-onChanged"); + }); + browser.downloads.onErased.addListener(() => { + browser.test.fail("download-onErased"); + }); + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "download") { + let { url, filename, downloadId } = data; + await browser.test.assertRejects( + browser.downloads.download({ + url, + filename, + incognito: true, + }), + /private browsing access not allowed/, + "cannot download using incognito without permission." + ); + + let downloads = await browser.downloads.search({ id: downloadId }); + browser.test.assertEq( + downloads.length, + 0, + "cannot search for incognito downloads" + ); + let erasing = await browser.downloads.erase({ id: downloadId }); + browser.test.assertEq( + erasing.length, + 0, + "cannot erase incognito download" + ); + + await browser.test.assertRejects( + browser.downloads.removeFile(downloadId), + /Invalid download id/, + "cannot remove incognito download" + ); + await browser.test.assertRejects( + browser.downloads.pause(downloadId), + /Invalid download id/, + "cannot pause incognito download" + ); + await browser.test.assertRejects( + browser.downloads.resume(downloadId), + /Invalid download id/, + "cannot resume incognito download" + ); + await browser.test.assertRejects( + browser.downloads.cancel(downloadId), + /Invalid download id/, + "cannot cancel incognito download" + ); + await browser.test.assertRejects( + browser.downloads.removeFile(downloadId), + /Invalid download id/, + "cannot remove incognito download" + ); + await browser.test.assertRejects( + browser.downloads.show(downloadId), + /Invalid download id/, + "cannot show incognito download" + ); + await browser.test.assertRejects( + browser.downloads.getFileIcon(downloadId), + /Invalid download id/, + "cannot show incognito download" + ); + } + if (msg == "download.open") { + let { downloadId } = data; + await browser.test.assertRejects( + browser.downloads.open(downloadId), + /Invalid download id/, + "cannot open incognito download" + ); + } + browser.test.sendMessage("continue"); + }); + }, + }); + + await extension.startup(); + await pb_extension.startup(); + await pb_extension.awaitMessage("ready"); + pb_extension.sendMessage({ + url: TXT_URL, + filename: TXT_FILE, + }); + let downloadId = await pb_extension.awaitMessage("downloadId"); + extension.sendMessage("download", { + url: TXT_URL, + filename: TXT_FILE, + downloadId, + }); + await extension.awaitMessage("continue"); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("download.open", { downloadId }); + await extension.awaitMessage("continue"); + }); + pb_extension.sendMessage("remove"); + + await pb_extension.awaitFinish("private download test done"); + await pb_extension.unload(); + await extension.unload(); +}); + +// Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1649463 +add_task(async function download_blob_in_perma_private_browsing() { + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + + // This script creates a blob:-URL and checks that the URL can be downloaded. + async function testScript() { + const blobUrl = URL.createObjectURL(new Blob(["data here"])); + const downloadId = await new Promise(resolve => { + browser.downloads.onChanged.addListener(delta => { + browser.test.log(`downloads.onChanged = ${JSON.stringify(delta)}`); + if (delta.state && delta.state.current !== "in_progress") { + resolve(delta.id); + } + }); + browser.downloads.download({ + url: blobUrl, + filename: "some-blob-download.txt", + }); + }); + + let [downloadItem] = await browser.downloads.search({ id: downloadId }); + browser.test.log(`Downloaded ${JSON.stringify(downloadItem)}`); + browser.test.assertEq(downloadItem.url, blobUrl, "expected blob URL"); + // TODO bug 1653636: should be true because of perma-private browsing. + // browser.test.assertTrue(downloadItem.incognito, "download is private"); + browser.test.assertFalse( + downloadItem.incognito, + "download is private [skipped - to be fixed in bug 1653636]" + ); + browser.test.assertTrue(downloadItem.exists, "download exists"); + await browser.downloads.removeFile(downloadId); + + browser.test.sendMessage("downloadDone"); + } + let pb_extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "@private-download-ext" } }, + permissions: ["downloads"], + }, + background: testScript, + incognitoOverride: "spanning", + files: { + "test_part2.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="test_part2.js"></script> + `, + "test_part2.js": testScript, + }, + }); + await pb_extension.startup(); + + info("Testing download of blob:-URL from extension's background page"); + await pb_extension.awaitMessage("downloadDone"); + + info("Testing download of blob:-URL with different userContextId"); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${pb_extension.uuid}/test_part2.html`, + { extension: pb_extension, userContextId: 2 } + ); + await pb_extension.awaitMessage("downloadDone"); + await contentPage.close(); + + await pb_extension.unload(); + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js new file mode 100644 index 0000000000..f28a4c881f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js @@ -0,0 +1,682 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.import( + "resource://gre/modules/Downloads.jsm" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; +const TXT_LEN = 46; +const HTML_FILE = "file_download.html"; +const HTML_URL = BASE + "/" + HTML_FILE; +const HTML_LEN = 117; +const EMPTY_FILE = "empty_file_download.txt"; +const EMPTY_URL = BASE + "/" + EMPTY_FILE; +const EMPTY_LEN = 0; +const BIG_LEN = 1000; // something bigger both TXT_LEN and HTML_LEN + +function backgroundScript() { + let complete = new Map(); + + function waitForComplete(id) { + if (complete.has(id)) { + return complete.get(id).promise; + } + + let promise = new Promise(resolve => { + complete.set(id, { resolve }); + }); + complete.get(id).promise = promise; + return promise; + } + + browser.downloads.onChanged.addListener(change => { + if (change.state && change.state.current == "complete") { + // Make sure we have a promise. + waitForComplete(change.id); + complete.get(change.id).resolve(); + } + }); + + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + try { + let id = await browser.downloads.download(args[0]); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "search.request") { + try { + let downloads = await browser.downloads.search(args[0]); + browser.test.sendMessage("search.done", { + status: "success", + downloads, + }); + } catch (error) { + browser.test.sendMessage("search.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "waitForComplete.request") { + await waitForComplete(args[0]); + browser.test.sendMessage("waitForComplete.done"); + } + }); + + browser.test.sendMessage("ready"); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +add_task(async function test_search() { + const nsIFile = Ci.nsIFile; + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + function downloadPath(filename) { + let path = downloadDir.clone(); + path.append(filename); + return path.path; + } + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); + await cleanupDir(downloadDir); + await clearDownloads(); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + async function download(options) { + extension.sendMessage("download.request", options); + let result = await extension.awaitMessage("download.done"); + + if (result.status == "success") { + info(`wait for onChanged event to indicate ${result.id} is complete`); + extension.sendMessage("waitForComplete.request", result.id); + + await extension.awaitMessage("waitForComplete.done"); + } + + return result; + } + + function search(query) { + extension.sendMessage("search.request", query); + return extension.awaitMessage("search.done"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Do some downloads... + const time1 = new Date(); + + let downloadIds = {}; + let msg = await download({ url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt1 = msg.id; + + const TXT_FILE2 = "NewFile.txt"; + msg = await download({ url: TXT_URL, filename: TXT_FILE2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt2 = msg.id; + + msg = await download({ url: EMPTY_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt3 = msg.id; + + const time2 = new Date(); + + msg = await download({ url: HTML_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.html1 = msg.id; + + const HTML_FILE2 = "renamed.html"; + msg = await download({ url: HTML_URL, filename: HTML_FILE2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.html2 = msg.id; + + const time3 = new Date(); + + // Search for each individual download and check + // the corresponding DownloadItem. + async function checkDownloadItem(id, expect) { + let item = await search({ id }); + equal(item.status, "success", "search() succeeded"); + equal(item.downloads.length, 1, "search() found exactly 1 download"); + + Object.keys(expect).forEach(function(field) { + equal( + item.downloads[0][field], + expect[field], + `DownloadItem.${field} is correct"` + ); + }); + } + await checkDownloadItem(downloadIds.txt1, { + url: TXT_URL, + filename: downloadPath(TXT_FILE), + mime: "text/plain", + state: "complete", + bytesReceived: TXT_LEN, + totalBytes: TXT_LEN, + fileSize: TXT_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.txt2, { + url: TXT_URL, + filename: downloadPath(TXT_FILE2), + mime: "text/plain", + state: "complete", + bytesReceived: TXT_LEN, + totalBytes: TXT_LEN, + fileSize: TXT_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.txt3, { + url: EMPTY_URL, + filename: downloadPath(EMPTY_FILE), + mime: "text/plain", + state: "complete", + bytesReceived: EMPTY_LEN, + totalBytes: EMPTY_LEN, + fileSize: EMPTY_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.html1, { + url: HTML_URL, + filename: downloadPath(HTML_FILE), + mime: "text/html", + state: "complete", + bytesReceived: HTML_LEN, + totalBytes: HTML_LEN, + fileSize: HTML_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.html2, { + url: HTML_URL, + filename: downloadPath(HTML_FILE2), + mime: "text/html", + state: "complete", + bytesReceived: HTML_LEN, + totalBytes: HTML_LEN, + fileSize: HTML_LEN, + exists: true, + }); + + async function checkSearch(query, expected, description, exact) { + let item = await search(query); + equal(item.status, "success", "search() succeeded"); + equal( + item.downloads.length, + expected.length, + `search() for ${description} found exactly ${expected.length} downloads` + ); + + let receivedIds = item.downloads.map(i => i.id); + if (exact) { + receivedIds.forEach((id, idx) => { + equal( + id, + downloadIds[expected[idx]], + `search() for ${description} returned ${expected[idx]} in position ${idx}` + ); + }); + } else { + Object.keys(downloadIds).forEach(key => { + const id = downloadIds[key]; + const thisExpected = expected.includes(key); + equal( + receivedIds.includes(id), + thisExpected, + `search() for ${description} ${ + thisExpected ? "includes" : "does not include" + } ${key}` + ); + }); + } + } + + // Check that search with an invalid id returns nothing. + // NB: for now ids are not persistent and we start numbering them at 1 + // so a sufficiently large number will be unused. + const INVALID_ID = 1000; + await checkSearch({ id: INVALID_ID }, [], "invalid id"); + + // Check that search on url works. + await checkSearch({ url: TXT_URL }, ["txt1", "txt2"], "url"); + + // Check that regexp on url works. + const HTML_REGEX = "[download]{8}.html+$"; + await checkSearch({ urlRegex: HTML_REGEX }, ["html1", "html2"], "url regexp"); + + // Check that compatible url+regexp works + await checkSearch( + { url: HTML_URL, urlRegex: HTML_REGEX }, + ["html1", "html2"], + "compatible url+urlRegex" + ); + + // Check that incompatible url+regexp works + await checkSearch( + { url: TXT_URL, urlRegex: HTML_REGEX }, + [], + "incompatible url+urlRegex" + ); + + // Check that search on filename works. + await checkSearch({ filename: downloadPath(TXT_FILE) }, ["txt1"], "filename"); + + // Check that regexp on filename works. + await checkSearch({ filenameRegex: HTML_REGEX }, ["html1"], "filename regex"); + + // Check that compatible filename+regexp works + await checkSearch( + { filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX }, + ["html1"], + "compatible filename+filename regex" + ); + + // Check that incompatible filename+regexp works + await checkSearch( + { filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX }, + [], + "incompatible filename+filename regex" + ); + + // Check that simple positive search terms work. + await checkSearch( + { query: ["file_download"] }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "term file_download" + ); + await checkSearch({ query: ["NewFile"] }, ["txt2"], "term NewFile"); + + // Check that positive search terms work case-insensitive. + await checkSearch({ query: ["nEwfILe"] }, ["txt2"], "term nEwfiLe"); + + // Check that negative search terms work. + await checkSearch({ query: ["-txt"] }, ["html1", "html2"], "term -txt"); + + // Check that positive and negative search terms together work. + await checkSearch( + { query: ["html", "-renamed"] }, + ["html1"], + "positive and negative terms" + ); + + async function checkSearchWithDate(query, expected, description) { + const fields = Object.keys(query); + if (fields.length != 1 || !(query[fields[0]] instanceof Date)) { + throw new Error("checkSearchWithDate expects exactly one Date field"); + } + const field = fields[0]; + const date = query[field]; + + let newquery = {}; + + // Check as a Date + newquery[field] = date; + await checkSearch(newquery, expected, `${description} as Date`); + + // Check as numeric milliseconds + newquery[field] = date.valueOf(); + await checkSearch(newquery, expected, `${description} as numeric ms`); + + // Check as stringified milliseconds + newquery[field] = date.valueOf().toString(); + await checkSearch(newquery, expected, `${description} as string ms`); + + // Check as ISO string + newquery[field] = date.toISOString(); + await checkSearch(newquery, expected, `${description} as iso string`); + } + + // Check startedBefore + await checkSearchWithDate({ startedBefore: time1 }, [], "before time1"); + await checkSearchWithDate( + { startedBefore: time2 }, + ["txt1", "txt2", "txt3"], + "before time2" + ); + await checkSearchWithDate( + { startedBefore: time3 }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "before time3" + ); + + // Check startedAfter + await checkSearchWithDate( + { startedAfter: time1 }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "after time1" + ); + await checkSearchWithDate( + { startedAfter: time2 }, + ["html1", "html2"], + "after time2" + ); + await checkSearchWithDate({ startedAfter: time3 }, [], "after time3"); + + // Check simple search on totalBytes + await checkSearch({ totalBytes: TXT_LEN }, ["txt1", "txt2"], "totalBytes"); + await checkSearch({ totalBytes: HTML_LEN }, ["html1", "html2"], "totalBytes"); + + // Check simple test on totalBytes{Greater,Less} + // (NB: TXT_LEN < HTML_LEN < BIG_LEN) + await checkSearch( + { totalBytesGreater: 0 }, + ["txt1", "txt2", "html1", "html2"], + "totalBytesGreater than 0" + ); + await checkSearch( + { totalBytesGreater: TXT_LEN }, + ["html1", "html2"], + `totalBytesGreater than ${TXT_LEN}` + ); + await checkSearch( + { totalBytesGreater: HTML_LEN }, + [], + `totalBytesGreater than ${HTML_LEN}` + ); + await checkSearch( + { totalBytesLess: TXT_LEN }, + ["txt3"], + `totalBytesLess than ${TXT_LEN}` + ); + await checkSearch( + { totalBytesLess: HTML_LEN }, + ["txt1", "txt2", "txt3"], + `totalBytesLess than ${HTML_LEN}` + ); + await checkSearch( + { totalBytesLess: BIG_LEN }, + ["txt1", "txt2", "txt3", "html1", "html2"], + `totalBytesLess than ${BIG_LEN}` + ); + + // Bug 1503760 check if 0 byte files with no search query are returned. + await checkSearch( + {}, + ["txt1", "txt2", "txt3", "html1", "html2"], + "totalBytesGreater than -1" + ); + + // Check good combinations of totalBytes*. + await checkSearch( + { totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN }, + ["html1", "html2"], + "totalBytes and totalBytesGreater" + ); + await checkSearch( + { totalBytes: TXT_LEN, totalBytesLess: HTML_LEN }, + ["txt1", "txt2"], + "totalBytes and totalBytesGreater" + ); + await checkSearch( + { totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0 }, + ["html1", "html2"], + "totalBytes and totalBytesLess and totalBytesGreater" + ); + + // Check bad combination of totalBytes*. + await checkSearch( + { totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN }, + [], + "bad totalBytesLess, totalBytesGreater combination" + ); + await checkSearch( + { totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN }, + [], + "bad totalBytes, totalBytesGreater combination" + ); + await checkSearch( + { totalBytes: HTML_LEN, totalBytesLess: TXT_LEN }, + [], + "bad totalBytes, totalBytesLess combination" + ); + + // Check mime. + await checkSearch( + { mime: "text/plain" }, + ["txt1", "txt2", "txt3"], + "mime text/plain" + ); + await checkSearch( + { mime: "text/html" }, + ["html1", "html2"], + "mime text/htmlplain" + ); + await checkSearch({ mime: "video/webm" }, [], "mime video/webm"); + + // Check fileSize. + await checkSearch({ fileSize: TXT_LEN }, ["txt1", "txt2"], "fileSize"); + await checkSearch({ fileSize: HTML_LEN }, ["html1", "html2"], "fileSize"); + + // Fields like bytesReceived, paused, state, exists are meaningful + // for downloads that are in progress but have not yet completed. + // todo: add tests for these when we have better support for in-progress + // downloads (e.g., after pause(), resume() and cancel() are implemented) + + // Check multiple query properties. + // We could make this testing arbitrarily complicated... + // We already tested combining fields with obvious interactions above + // (e.g., filename and filenameRegex or startTime and startedBefore/After) + // so now just throw as many fields as we can at a single search and + // make sure a simple case still works. + await checkSearch( + { + url: TXT_URL, + urlRegex: "download", + filename: downloadPath(TXT_FILE), + filenameRegex: "download", + query: ["download"], + startedAfter: time1.valueOf().toString(), + startedBefore: time2.valueOf().toString(), + totalBytes: TXT_LEN, + totalBytesGreater: 0, + totalBytesLess: BIG_LEN, + mime: "text/plain", + fileSize: TXT_LEN, + }, + ["txt1"], + "many properties" + ); + + // Check simple orderBy (forward and backward). + await checkSearch( + { orderBy: ["startTime"] }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "orderBy startTime", + true + ); + await checkSearch( + { orderBy: ["-startTime"] }, + ["html2", "html1", "txt3", "txt2", "txt1"], + "orderBy -startTime", + true + ); + + // Check orderBy with multiple fields. + // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt + // EMPTY_URL begins with e which precedes f + await checkSearch( + { orderBy: ["url", "-startTime"] }, + ["txt3", "html2", "html1", "txt2", "txt1"], + "orderBy with multiple fields", + true + ); + + // Check orderBy with limit. + await checkSearch( + { orderBy: ["url"], limit: 1 }, + ["txt3"], + "orderBy with limit", + true + ); + + // Check bad arguments. + async function checkBadSearch(query, pattern, description) { + let item = await search(query); + equal(item.status, "error", "search() failed"); + ok( + pattern.test(item.errmsg), + `error message for ${description} was correct (${item.errmsg}).` + ); + } + + await checkBadSearch( + "myquery", + /Incorrect argument type/, + "query is not an object" + ); + await checkBadSearch( + { bogus: "boo" }, + /Unexpected property/, + "query contains an unknown field" + ); + await checkBadSearch( + { query: "query string" }, + /Expected array/, + "query.query is a string" + ); + await checkBadSearch( + { startedBefore: "i am not a time" }, + /Type error/, + "query.startedBefore is not a valid time" + ); + await checkBadSearch( + { startedAfter: "i am not a time" }, + /Type error/, + "query.startedAfter is not a valid time" + ); + await checkBadSearch( + { endedBefore: "i am not a time" }, + /Type error/, + "query.endedBefore is not a valid time" + ); + await checkBadSearch( + { endedAfter: "i am not a time" }, + /Type error/, + "query.endedAfter is not a valid time" + ); + await checkBadSearch( + { urlRegex: "[" }, + /Invalid urlRegex/, + "query.urlRegexp is not a valid regular expression" + ); + await checkBadSearch( + { filenameRegex: "[" }, + /Invalid filenameRegex/, + "query.filenameRegexp is not a valid regular expression" + ); + await checkBadSearch( + { orderBy: "startTime" }, + /Expected array/, + "query.orderBy is not an array" + ); + await checkBadSearch( + { orderBy: ["bogus"] }, + /Invalid orderBy field/, + "query.orderBy references a non-existent field" + ); + + await extension.unload(); +}); + +// Test that downloads with totalBytes of -1 (ie, that have not yet started) +// work properly. See bug 1519762 for details of a past regression in +// this area. +add_task(async function test_inprogress() { + let resume, + resumePromise = new Promise(resolve => { + resume = resolve; + }); + let hit = false; + server.registerPathHandler("/data/slow", async (request, response) => { + hit = true; + response.processAsync(); + await resumePromise; + response.setHeader("Content-type", "text/plain"); + response.write(""); + response.finish(); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background() { + browser.test.onMessage.addListener(async (msg, url) => { + let id = await browser.downloads.download({ url }); + let full = await browser.downloads.search({ id }); + + browser.test.assertEq( + full.length, + 1, + "Found new download in search results" + ); + browser.test.assertEq( + full[0].totalBytes, + -1, + "New download still has totalBytes == -1" + ); + + browser.downloads.onChanged.addListener(info => { + if (info.id == id && info.state && info.state.current == "complete") { + browser.test.notifyPass("done"); + } + }); + + browser.test.sendMessage("started"); + }); + }, + }); + + await extension.startup(); + extension.sendMessage("go", `${BASE}/slow`); + await extension.awaitMessage("started"); + resume(); + await extension.awaitFinish("done"); + await extension.unload(); + Assert.ok(hit, "slow path was actually hit"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js new file mode 100644 index 0000000000..9a63369efb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js @@ -0,0 +1,235 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.import( + "resource://gre/modules/Downloads.jsm" +); + +function backgroundScript() { + let complete = new Map(); + + function waitForComplete(id) { + if (complete.has(id)) { + return complete.get(id).promise; + } + + let promise = new Promise(resolve => { + complete.set(id, { resolve }); + }); + complete.get(id).promise = promise; + return promise; + } + + browser.downloads.onChanged.addListener(change => { + if (change.state && change.state.current == "complete") { + // Make sure we have a promise. + waitForComplete(change.id); + complete.get(change.id).resolve(); + } + }); + + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + try { + let id = await browser.downloads.download(args[0]); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "search.request") { + try { + let downloads = await browser.downloads.search(args[0]); + browser.test.sendMessage("search.done", { + status: "success", + downloads, + }); + } catch (error) { + browser.test.sendMessage("search.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "waitForComplete.request") { + await waitForComplete(args[0]); + browser.test.sendMessage("waitForComplete.done"); + } + }); + + browser.test.sendMessage("ready"); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +add_task(async function test_decoded_filename_download() { + const server = createHttpServer(); + server.registerPrefixHandler("/data/", (_, res) => res.write("length=8")); + + const BASE = `http://localhost:${server.identity.primaryPort}/data`; + const FILE_NAME_ENCODED_1 = "file%2Fencode.txt"; + const FILE_NAME_DECODED_1 = "file_encode.txt"; + const FILE_NAME_ENCODED_URL_1 = BASE + "/" + FILE_NAME_ENCODED_1; + const FILE_NAME_ENCODED_2 = "file%F0%9F%9A%B2encoded.txt"; + const FILE_NAME_DECODED_2 = "file\u{0001F6B2}encoded.txt"; + const FILE_NAME_ENCODED_URL_2 = BASE + "/" + FILE_NAME_ENCODED_2; + const FILE_NAME_ENCODED_3 = "file%X%20encode.txt"; + const FILE_NAME_DECODED_3 = "file%X encode.txt"; + const FILE_NAME_ENCODED_URL_3 = BASE + "/" + FILE_NAME_ENCODED_3; + const FILE_ENCODED_LEN = 8; + + const nsIFile = Ci.nsIFile; + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + function downloadPath(filename) { + let path = downloadDir.clone(); + path.append(filename); + return path.path; + } + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + await cleanupDir(downloadDir); + await clearDownloads(); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + async function download(options) { + extension.sendMessage("download.request", options); + let result = await extension.awaitMessage("download.done"); + + if (result.status == "success") { + info(`wait for onChanged event to indicate ${result.id} is complete`); + extension.sendMessage("waitForComplete.request", result.id); + + await extension.awaitMessage("waitForComplete.done"); + } + + return result; + } + + function search(query) { + extension.sendMessage("search.request", query); + return extension.awaitMessage("search.done"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + let downloadIds = {}; + let msg = await download({ url: FILE_NAME_ENCODED_URL_1 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded1 = msg.id; + + msg = await download({ url: FILE_NAME_ENCODED_URL_2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded2 = msg.id; + + msg = await download({ url: FILE_NAME_ENCODED_URL_3 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded3 = msg.id; + + // Search for each individual download and check + // the corresponding DownloadItem. + async function checkDownloadItem(id, expect) { + let item = await search({ id }); + equal(item.status, "success", "search() succeeded"); + equal(item.downloads.length, 1, "search() found exactly 1 download"); + Object.keys(expect).forEach(function(field) { + equal( + item.downloads[0][field], + expect[field], + `DownloadItem.${field} is correct"` + ); + }); + } + + await checkDownloadItem(downloadIds.fileEncoded1, { + url: FILE_NAME_ENCODED_URL_1, + filename: downloadPath(FILE_NAME_DECODED_1), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.fileEncoded2, { + url: FILE_NAME_ENCODED_URL_2, + filename: downloadPath(FILE_NAME_DECODED_2), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.fileEncoded3, { + url: FILE_NAME_ENCODED_URL_3, + filename: downloadPath(FILE_NAME_DECODED_3), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + // Searching for downloads by the decoded filename works correctly. + async function checkSearch(query, expected, description) { + let item = await search(query); + equal(item.status, "success", "search() succeeded"); + equal( + item.downloads.length, + expected.length, + `search() for ${description} found exactly ${expected.length} downloads` + ); + equal( + item.downloads[0].id, + downloadIds[expected[0]], + `search() for ${description} returned ${expected[0]} in position ${0}` + ); + } + + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_1) }, + ["fileEncoded1"], + "filename" + ); + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_2) }, + ["fileEncoded2"], + "filename" + ); + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_3) }, + ["fileEncoded3"], + "filename" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js new file mode 100644 index 0000000000..ab18c9c371 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_error_location() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let { fileName } = new Error(); + + browser.test.sendMessage("fileName", fileName); + + browser.runtime.sendMessage("Meh.", () => {}); + + await browser.test.assertRejects( + browser.runtime.sendMessage("Meh"), + error => { + return error.fileName === fileName && error.lineNumber === 9; + } + ); + + browser.test.notifyPass("error-location"); + }, + }); + + let fileName; + const { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + + fileName = await extension.awaitMessage("fileName"); + + await extension.awaitFinish("error-location"); + + await extension.unload(); + }); + + let [msg] = messages.filter(m => m.message.includes("Unchecked lastError")); + + equal(msg.sourceName, fileName, "Message source"); + equal(msg.lineNumber, 6, "Message line"); + + let frame = msg.stack; + if (frame) { + equal(frame.source, fileName, "Frame source"); + equal(frame.line, 6, "Frame line"); + equal(frame.column, 23, "Frame column"); + equal(frame.functionDisplayName, "background", "Frame function name"); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js new file mode 100644 index 0000000000..ba53803f43 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js @@ -0,0 +1,90 @@ +"use strict"; + +AddonTestUtils.init(this); +// This test expects and checks deprecation warnings. +ExtensionTestUtils.failOnSchemaWarnings(false); + +function createEventPageExtension(eventPage) { + return ExtensionTestUtils.loadExtension({ + manifest: { + background: eventPage, + }, + files: { + "event_page_script.js"() { + browser.test.log("running event page as background script"); + browser.test.sendMessage("running", 1); + }, + "event-page.html": `<!DOCTYPE html> + <html><head> + <meta charset="utf-8"> + <script src="event_page_script.js"><\/script> + </head></html>`, + }, + }); +} + +add_task(async function test_eventpages() { + let testCases = [ + { + message: "testing event page running as a background page", + eventPage: { + page: "event-page.html", + persistent: false, + }, + }, + { + message: "testing event page scripts running as a background page", + eventPage: { + scripts: ["event_page_script.js"], + persistent: false, + }, + }, + { + message: "testing additional unrecognized properties on background page", + eventPage: { + scripts: ["event_page_script.js"], + nonExistentProp: true, + }, + }, + { + message: "testing persistent background page", + eventPage: { + page: "event-page.html", + persistent: true, + }, + }, + { + message: + "testing scripts with persistent background running as a background page", + eventPage: { + scripts: ["event_page_script.js"], + persistent: true, + }, + }, + ]; + + let { messages } = await promiseConsoleOutput(async () => { + for (let test of testCases) { + info(test.message); + + let extension = createEventPageExtension(test.eventPage); + await extension.startup(); + let x = await extension.awaitMessage("running"); + equal(x, 1, "got correct value from extension"); + await extension.unload(); + } + }); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { message: /Event pages are not currently supported./ }, + { message: /Event pages are not currently supported./ }, + { + message: /Reading manifest: Warning processing background.nonExistentProp: An unexpected property was found/, + }, + ], + }, + true + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js new file mode 100644 index 0000000000..1393888eca --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js @@ -0,0 +1,358 @@ +"use strict"; + +/* globals browser */ +const { AddonSettings } = ChromeUtils.import( + "resource://gre/modules/addons/AddonSettings.jsm" +); + +AddonTestUtils.init(this); + +add_task(async function setup() { + AddonTestUtils.overrideCertDB(); + await ExtensionTestUtils.startAddonManager(); +}); + +let fooExperimentAPIs = { + foo: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments", "foo", "parent"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["experiments", "foo", "child"]], + }, + }, +}; + +let fooExperimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "experiments.foo", + types: [ + { + id: "Meh", + type: "object", + properties: {}, + }, + ], + functions: [ + { + name: "parent", + type: "function", + async: true, + parameters: [], + }, + { + name: "child", + type: "function", + parameters: [], + returns: { type: "string" }, + }, + ], + }, + ]), + + /* globals ExtensionAPI */ + "parent.js": () => { + this.foo = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + foo: { + parent() { + return Promise.resolve("parent"); + }, + }, + }, + }; + } + }; + }, + + "child.js": () => { + this.foo = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + foo: { + child() { + return "child"; + }, + }, + }, + }; + } + }; + }, +}; + +async function testFooExperiment() { + browser.test.assertEq( + "object", + typeof browser.experiments, + "typeof browser.experiments" + ); + + browser.test.assertEq( + "object", + typeof browser.experiments.foo, + "typeof browser.experiments.foo" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.foo.child, + "typeof browser.experiments.foo.child" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.foo.parent, + "typeof browser.experiments.foo.parent" + ); + + browser.test.assertEq( + "child", + browser.experiments.foo.child(), + "foo.child()" + ); + + browser.test.assertEq( + "parent", + await browser.experiments.foo.parent(), + "await foo.parent()" + ); +} + +async function testFooFailExperiment() { + browser.test.assertEq( + "object", + typeof browser.experiments, + "typeof browser.experiments" + ); + + browser.test.assertEq( + "undefined", + typeof browser.experiments.foo, + "typeof browser.experiments.foo" + ); +} + +add_task(async function test_bundled_experiments() { + let testCases = [ + { isSystem: true, temporarilyInstalled: true, shouldHaveExperiments: true }, + { + isSystem: true, + temporarilyInstalled: false, + shouldHaveExperiments: true, + }, + { + isPrivileged: true, + temporarilyInstalled: true, + shouldHaveExperiments: true, + }, + { + isPrivileged: true, + temporarilyInstalled: false, + shouldHaveExperiments: true, + }, + { + isPrivileged: false, + temporarilyInstalled: true, + shouldHaveExperiments: AddonSettings.EXPERIMENTS_ENABLED, + }, + { + isPrivileged: false, + temporarilyInstalled: false, + shouldHaveExperiments: AppConstants.MOZ_APP_NAME == "thunderbird", + }, + ]; + + async function background(shouldHaveExperiments) { + if (shouldHaveExperiments) { + await testFooExperiment(); + } else { + await testFooFailExperiment(); + } + + browser.test.notifyPass("background.experiments.foo"); + } + + for (let testCase of testCases) { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: testCase.isPrivileged, + isSystem: testCase.isSystem, + temporarilyInstalled: testCase.temporarilyInstalled, + + manifest: { + experiment_apis: fooExperimentAPIs, + }, + + background: ` + ${testFooExperiment} + ${testFooFailExperiment} + (${background})(${testCase.shouldHaveExperiments}); + `, + + files: fooExperimentFiles, + }); + + await extension.startup(); + + await extension.awaitFinish("background.experiments.foo"); + + await extension.unload(); + } +}); + +add_task(async function test_unbundled_experiments() { + async function background() { + await testFooExperiment(); + + browser.test.assertEq( + "object", + typeof browser.experiments.crunk, + "typeof browser.experiments.crunk" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.crunk.child, + "typeof browser.experiments.crunk.child" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.crunk.parent, + "typeof browser.experiments.crunk.parent" + ); + + browser.test.assertEq( + "crunk-child", + browser.experiments.crunk.child(), + "crunk.child()" + ); + + browser.test.assertEq( + "crunk-parent", + await browser.experiments.crunk.parent(), + "await crunk.parent()" + ); + + browser.test.notifyPass("background.experiments.crunk"); + } + + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + + manifest: { + experiment_apis: fooExperimentAPIs, + + permissions: ["experiments.crunk"], + }, + + background: ` + ${testFooExperiment} + (${background})(); + `, + + files: fooExperimentFiles, + }); + + let apiExtension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + + manifest: { + applications: { gecko: { id: "crunk@experiments.addons.mozilla.org" } }, + + experiment_apis: { + crunk: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments", "crunk", "parent"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["experiments", "crunk", "child"]], + }, + }, + }, + }, + + files: { + "schema.json": JSON.stringify([ + { + namespace: "experiments.crunk", + types: [ + { + id: "Meh", + type: "object", + properties: {}, + }, + ], + functions: [ + { + name: "parent", + type: "function", + async: true, + parameters: [], + }, + { + name: "child", + type: "function", + parameters: [], + returns: { type: "string" }, + }, + ], + }, + ]), + + "parent.js": () => { + this.crunk = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + crunk: { + parent() { + return Promise.resolve("crunk-parent"); + }, + }, + }, + }; + } + }; + }, + + "child.js": () => { + this.crunk = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + crunk: { + child() { + return "crunk-child"; + }, + }, + }, + }; + } + }; + }, + }, + }); + + await apiExtension.startup(); + await extension.startup(); + + await extension.awaitFinish("background.experiments.crunk"); + + await extension.unload(); + await apiExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js new file mode 100644 index 0000000000..72fa161965 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_is_allowed_incognito_access() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + async function background() { + let allowed = await browser.extension.isAllowedIncognitoAccess(); + + browser.test.assertEq(true, allowed, "isAllowedIncognitoAccess is true"); + browser.test.notifyPass("isAllowedIncognitoAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitFinish("isAllowedIncognitoAccess"); + await extension.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); + +add_task(async function test_is_denied_incognito_access() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + async function background() { + let allowed = await browser.extension.isAllowedIncognitoAccess(); + + browser.test.assertEq(false, allowed, "isAllowedIncognitoAccess is false"); + browser.test.notifyPass("isNotAllowedIncognitoAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("isNotAllowedIncognitoAccess"); + await extension.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); + +add_task(async function test_in_incognito_context_false() { + function background() { + browser.test.assertEq( + false, + browser.extension.inIncognitoContext, + "inIncognitoContext returned false" + ); + browser.test.notifyPass("inIncognitoContext"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("inIncognitoContext"); + await extension.unload(); +}); + +add_task(async function test_is_allowed_file_scheme_access() { + async function background() { + let allowed = await browser.extension.isAllowedFileSchemeAccess(); + + browser.test.assertEq(false, allowed, "isAllowedFileSchemeAccess is false"); + browser.test.notifyPass("isAllowedFileSchemeAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("isAllowedFileSchemeAccess"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js new file mode 100644 index 0000000000..19e046e12d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js @@ -0,0 +1,887 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ExtensionSettingsStore", + "resource://gre/modules/ExtensionSettingsStore.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); +var { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +let lastSetPref; + +const STORE_TYPE = "prefs"; + +// Test settings to use with the preferences manager. +const SETTINGS = { + multiple_prefs: { + prefNames: ["my.pref.1", "my.pref.2", "my.pref.3"], + + initalValues: ["value1", "value2", "value3"], + + valueFn(pref, value) { + return `${pref}-${value}`; + }, + + setCallback(value) { + let prefs = {}; + for (let pref of this.prefNames) { + prefs[pref] = this.valueFn(pref, value); + } + return prefs; + }, + }, + + singlePref: { + prefNames: ["my.single.pref"], + + initalValues: ["value1"], + + onPrefsChanged(item) { + lastSetPref = item; + }, + + valueFn(pref, value) { + return value; + }, + + setCallback(value) { + return { [this.prefNames[0]]: this.valueFn(null, value) }; + }, + }, +}; + +ExtensionPreferencesManager.addSetting( + "multiple_prefs", + SETTINGS.multiple_prefs +); +ExtensionPreferencesManager.addSetting("singlePref", SETTINGS.singlePref); + +// Set initial values for prefs. +for (let setting in SETTINGS) { + setting = SETTINGS[setting]; + for (let i = 0; i < setting.prefNames.length; i++) { + Preferences.set(setting.prefNames[i], setting.initalValues[i]); + } +} + +function checkPrefs(settingObj, value, msg) { + for (let pref of settingObj.prefNames) { + equal(Preferences.get(pref), settingObj.valueFn(pref, value), msg); + } +} + +function checkOnPrefsChanged(setting, value, msg) { + if (value) { + deepEqual(lastSetPref, value, msg); + lastSetPref = null; + } else { + ok(!lastSetPref, msg); + } +} + +add_task(async function test_preference_manager() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let extensions = testExtensions.map(extension => extension.extension); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + let newValue1 = "newValue1"; + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged has not been called yet" + ); + } + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl with no settings set." + ); + + let prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[1].id, + setting, + newValue1 + ); + ok(prefsChanged, "setSetting returns true when the pref(s) have been set."); + checkPrefs( + settingObj, + newValue1, + "setSetting sets the prefs for the first extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[1].id, value: newValue1, key: setting }, + "onPrefsChanged is called when pref changes" + ); + } + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl when a pref has been set." + ); + + let checkSetting = await ExtensionPreferencesManager.getSetting(setting); + equal( + checkSetting.value, + newValue1, + "getSetting returns the expected value." + ); + + let newValue2 = "newValue2"; + prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue2 + ); + ok( + !prefsChanged, + "setSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "setSetting does not set the pref(s) for an earlier extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change" + ); + } + + prefsChanged = await ExtensionPreferencesManager.disableSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "disableSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "disableSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on disable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.enableSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "enableSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "enableSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on enable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "removeSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "removeSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on remove" + ); + } + + prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue2 + ); + ok( + !prefsChanged, + "setSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "setSetting does not set the pref(s) for an earlier extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change again" + ); + } + + prefsChanged = await ExtensionPreferencesManager.disableSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "disableSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue2, + "disableSetting sets the pref(s) to the next value when disabling the top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[0].id, key: setting, value: newValue2 }, + "onPrefsChanged is called when control changes on disable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.enableSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "enableSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue1, + "enableSetting sets the pref(s) to the previous value(s)." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[1].id, key: setting, value: newValue1 }, + "onPrefsChanged is called when control changes on enable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "removeSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue2, + "removeSetting sets the pref(s) to the next value when removing the top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[0].id, key: setting, value: newValue2 }, + "onPrefsChanged is called when control changes on remove" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[0].id, + setting + ); + ok( + prefsChanged, + "removeSetting returns true when the pref(s) have been set." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { key: setting, initialValue: { "my.single.pref": "value1" } }, + "onPrefsChanged is called when control is entirely removed" + ); + } + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "removeSetting sets the pref(s) to the initial value(s) when removing the last extension." + ); + } + + checkSetting = await ExtensionPreferencesManager.getSetting(setting); + equal( + checkSetting, + null, + "getSetting returns null when nothing has been set." + ); + } + + // Tests for unsetAll. + let newValue3 = "newValue3"; + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue3 + ); + checkPrefs(settingObj, newValue3, "setSetting set the pref."); + } + + let setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual( + setSettings, + Object.keys(SETTINGS), + "Expected settings were set for extension." + ); + await ExtensionPreferencesManager.disableAll(extensions[0].id); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "disableAll unset the pref." + ); + } + } + + setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual( + setSettings, + Object.keys(SETTINGS), + "disableAll retains the settings." + ); + + await ExtensionPreferencesManager.enableAll(extensions[0].id); + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + checkPrefs(settingObj, newValue3, "enableAll re-set the pref."); + } + + await ExtensionPreferencesManager.removeAll(extensions[0].id); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "removeAll unset the pref." + ); + } + } + + setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual(setSettings, [], "removeAll removed all settings."); + + // Tests for preventing automatic changes to manually edited prefs. + for (let setting in SETTINGS) { + let apiValue = "newValue"; + let manualValue = "something different"; + let settingObj = SETTINGS[setting]; + let extension = extensions[1]; + await ExtensionPreferencesManager.setSetting( + extension.id, + setting, + apiValue + ); + + let checkResetPrefs = method => { + let prefNames = settingObj.prefNames; + for (let i = 0; i < prefNames.length; i++) { + if (i === 0) { + equal( + Preferences.get(prefNames[0]), + manualValue, + `${method} did not change a manually set pref.` + ); + } else { + equal( + Preferences.get(prefNames[i]), + settingObj.valueFn(prefNames[i], apiValue), + `${method} did not change another pref when a pref was manually set.` + ); + } + } + }; + + // Manually set the preference to a different value. + Preferences.set(settingObj.prefNames[0], manualValue); + + await ExtensionPreferencesManager.disableAll(extension.id); + checkResetPrefs("disableAll"); + + await ExtensionPreferencesManager.enableAll(extension.id); + checkResetPrefs("enableAll"); + + await ExtensionPreferencesManager.removeAll(extension.id); + checkResetPrefs("removeAll"); + } + + // Test with an uninitialized pref. + let setting = "singlePref"; + let settingObj = SETTINGS[setting]; + let pref = settingObj.prefNames[0]; + let newValue = "newValue"; + Preferences.reset(pref); + await ExtensionPreferencesManager.setSetting( + extensions[1].id, + setting, + newValue + ); + equal( + Preferences.get(pref), + settingObj.valueFn(pref, newValue), + "Uninitialized pref is set." + ); + await ExtensionPreferencesManager.removeSetting(extensions[1].id, setting); + ok(!Preferences.has(pref), "removeSetting removed the pref."); + + // Test levelOfControl with a locked pref. + setting = "multiple_prefs"; + let prefToLock = SETTINGS[setting].prefNames[0]; + Preferences.lock(prefToLock, 1); + ok(Preferences.locked(prefToLock), `Preference ${prefToLock} is locked.`); + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when a pref is locked." + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_preference_manager_set_when_disabled() { + await promiseStartupManager(); + + let id = "@set-disabled-pref"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + + // We test both a default pref and a user-set pref. Get the default + // value off the pref we'll use. We fake the default pref by setting + // a value on it before creating the setting. + Services.prefs.setBoolPref("bar", true); + + function isUndefinedPref(pref) { + try { + Services.prefs.getStringPref(pref); + return false; + } catch (e) { + return true; + } + } + ok(isUndefinedPref("foo"), "test pref is not set"); + + await ExtensionSettingsStore.initialize(); + let lastItemChange = PromiseUtils.defer(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["foo", "bar"], + onPrefsChanged(item) { + lastItemChange.resolve(item); + lastItemChange = PromiseUtils.defer(); + }, + setCallback(value) { + return { [this.prefNames[0]]: value, [this.prefNames[1]]: false }; + }, + }); + + await ExtensionPreferencesManager.setSetting(id, "some-pref", "my value"); + + let item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "my value", "The value has been set"); + equal( + Services.prefs.getStringPref("foo"), + "my value", + "The user pref has been set" + ); + equal( + Services.prefs.getBoolPref("bar"), + false, + "The default pref has been set" + ); + + await ExtensionPreferencesManager.disableSetting(id, "some-pref"); + + // test that a disabled setting has been returned to the default value. In this + // case the pref is not a default pref, so it will be undefined. + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, undefined, "The value is back to default"); + equal(item.initialValue.foo, undefined, "The initialValue is correct"); + ok(isUndefinedPref("foo"), "user pref is not set"); + equal( + Services.prefs.getBoolPref("bar"), + true, + "The default pref has been restored to the default" + ); + + // test that setSetting() will enable a disabled setting + await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value"); + + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is set again"); + equal( + Services.prefs.getStringPref("foo"), + "new value", + "The user pref is set again" + ); + equal( + Services.prefs.getBoolPref("bar"), + false, + "The default pref has been set again" + ); + + // Force settings to be serialized and reloaded to mimick what happens + // with settings through a restart of Firefox. Bug 1576266. + await ExtensionSettingsStore._reloadFile(true); + + // Now unload the extension to test prefs are reset properly. + let promise = lastItemChange.promise; + await extension.unload(); + + // Test that the pref is unset when an extension is uninstalled. + item = await promise; + deepEqual( + item, + { key: "some-pref", initialValue: { bar: true } }, + "The value has been reset" + ); + ok(isUndefinedPref("foo"), "user pref is not set"); + equal( + Services.prefs.getBoolPref("bar"), + true, + "The default pref has been restored to the default" + ); + Services.prefs.clearUserPref("bar"); + + await promiseShutdownManager(); +}); + +add_task(async function test_preference_default_upgraded() { + await promiseStartupManager(); + + let id = "@upgrade-pref"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + + // We set the default value for a pref here so it will be + // picked up by EPM. + let defaultPrefs = Services.prefs.getDefaultBranch(null); + defaultPrefs.setStringPref("bar", "initial default"); + + await ExtensionSettingsStore.initialize(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["bar"], + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + }); + + await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value"); + let item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is set"); + + defaultPrefs.setStringPref("bar", "new default"); + + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is still set"); + + let prefsChanged = await ExtensionPreferencesManager.removeSetting( + id, + "some-pref" + ); + ok(prefsChanged, "pref changed on removal of setting."); + equal(Preferences.get("bar"), "new default", "default value is correct"); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_preference_select() { + await promiseStartupManager(); + + let extensionData = { + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@one" } }, + }, + }; + let one = ExtensionTestUtils.loadExtension(extensionData); + + await one.startup(); + + // We set the default value for a pref here so it will be + // picked up by EPM. + let defaultPrefs = Services.prefs.getDefaultBranch(null); + defaultPrefs.setStringPref("bar", "initial default"); + + await ExtensionSettingsStore.initialize(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["bar"], + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + }); + + ok( + await ExtensionPreferencesManager.setSetting( + one.id, + "some-pref", + "new value" + ), + "setting was changed" + ); + let item = await ExtensionPreferencesManager.getSetting("some-pref"); + equal(item.value, "new value", "The value is set"); + + // User-set the setting. + await ExtensionPreferencesManager.selectSetting(null, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + // Extensions installed before cannot gain control again. + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + one.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set." + ); + + // Enabling the top-precedence addon does not take over a user-set setting. + await ExtensionPreferencesManager.disableSetting(one.id, "some-pref"); + await ExtensionPreferencesManager.enableSetting(one.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + // Upgrading does not override the user-set setting. + extensionData.manifest.version = "2.0"; + extensionData.manifest.incognito = "not_allowed"; + await one.upgrade(extensionData); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + one.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set after addon upgrade." + ); + + // We can re-select the extension. + await ExtensionPreferencesManager.selectSetting(one.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual(item.value, "new value", "The value is extension set"); + + // An extension installed after user-set can take over the setting. + await ExtensionPreferencesManager.selectSetting(null, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + let two = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@two" } }, + }, + }); + + await two.startup(); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + two.id, + "some-pref" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + await ExtensionPreferencesManager.setSetting( + two.id, + "some-pref", + "another value" + ); + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "another value", "The value is set"); + + // A new installed extension can override a user selected extension. + let three = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@three" } }, + }, + }); + + // user selects specific extension to take control + await ExtensionPreferencesManager.selectSetting(one.id, "some-pref"); + + // two cannot control + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + two.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + // three can control after install + await three.startup(); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + three.id, + "some-pref" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + await ExtensionPreferencesManager.setSetting( + three.id, + "some-pref", + "third value" + ); + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "third value", "The value is set"); + + // We have returned to precedence based settings. + await ExtensionPreferencesManager.removeSetting(three.id, "some-pref"); + await ExtensionPreferencesManager.removeSetting(two.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + equal(item.value, "new value", "The value is extension set"); + + await one.unload(); + await two.unload(); + await three.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_preference_select() { + let prefNames = await ExtensionPreferencesManager.getManagedPrefDetails(); + // Just check a subset of settings that are in this test file. + Assert.ok(prefNames.size > 0, "some prefs exist"); + for (let settingName in SETTINGS) { + let setting = SETTINGS[settingName]; + for (let prefName of setting.prefNames) { + Assert.equal( + prefNames.get(prefName), + settingName, + "setting retrieved prefNames" + ); + } + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js new file mode 100644 index 0000000000..e4baa79a2c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js @@ -0,0 +1,1089 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionSettingsStore", + "resource://gre/modules/ExtensionSettingsStore.jsm" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +const ITEMS = { + key1: [ + { key: "key1", value: "val1", id: "@first" }, + { key: "key1", value: "val2", id: "@second" }, + { key: "key1", value: "val3", id: "@third" }, + ], + key2: [ + { key: "key2", value: "val1-2", id: "@first" }, + { key: "key2", value: "val2-2", id: "@second" }, + { key: "key2", value: "val3-2", id: "@third" }, + ], +}; +const KEY_LIST = Object.keys(ITEMS); +const TEST_TYPE = "myType"; + +let callbackCount = 0; + +function initialValue(key) { + callbackCount++; + return `key:${key}`; +} + +add_task(async function test_settings_store() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@second" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@third" } }, + }, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let extensions = testExtensions.map(extension => extension.extension); + + let expectedCallbackCount = 0; + + await Assert.rejects( + ExtensionSettingsStore.getLevelOfControl(1, TEST_TYPE, "key"), + /The ExtensionSettingsStore was accessed before the initialize promise resolved/, + "Accessing the SettingsStore before it is initialized throws an error." + ); + + // Initialize the SettingsStore. + await ExtensionSettingsStore.initialize(); + + // Add a setting for the second oldest extension, where it is the only setting for a key. + for (let key of KEY_LIST) { + let extensionIndex = 1; + let itemToAdd = ITEMS[key][extensionIndex]; + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl with no settings set for a key." + ); + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + expectedCallbackCount++; + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Adding initial item for a key returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item with only one item in the list." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl with only one item in the list." + ); + ok( + ExtensionSettingsStore.hasSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ), + "hasSetting returns the correct value when an extension has a setting set." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with only one item in the list." + ); + } + + // Add a setting for the oldest extension. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let itemToAdd = ITEMS[key][extensionIndex]; + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + equal( + item, + null, + "An older extension adding a setting for a key returns null" + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item with more than one item in the list." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl when another extension is in control." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with more than one item in the list." + ); + } + + // Reload the settings store to emulate a browser restart. + await ExtensionSettingsStore._reloadFile(); + + // Add a setting for the newest extension. + for (let key of KEY_LIST) { + let extensionIndex = 2; + let itemToAdd = ITEMS[key][extensionIndex]; + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl for a more recent extension." + ); + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Adding item for most recent extension returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item with more than one item in the list." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl when this extension is in control." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with more than one item in the list." + ); + } + + for (let extension of extensions) { + let items = await ExtensionSettingsStore.getAllForExtension( + extension.id, + TEST_TYPE + ); + deepEqual(items, KEY_LIST, "getAllForExtension returns expected keys."); + } + + // Attempting to remove a setting that has not been set should *not* throw an exception. + let removeResult = await ExtensionSettingsStore.removeSetting( + extensions[0].id, + "myType", + "unset_key" + ); + equal( + removeResult, + null, + "Removing a setting that was not previously set returns null." + ); + + // Attempting to disable a setting that has not been set should throw an exception. + Assert.throws( + () => + ExtensionSettingsStore.disable(extensions[0].id, "myType", "unset_key"), + /Cannot alter the setting for myType:unset_key as it does not exist/, + "disable rejects with an unset key." + ); + + // Attempting to enable a setting that has not been set should throw an exception. + Assert.throws( + () => + ExtensionSettingsStore.enable(extensions[0].id, "myType", "unset_key"), + /Cannot alter the setting for myType:unset_key as it does not exist/, + "enable rejects with an unset key." + ); + + let expectedKeys = KEY_LIST; + // Disable the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key, + "new value", + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + equal(item, null, "Updating non-top item for a key returns null"); + item = await ExtensionSettingsStore.disable( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Disabling non-top item for a key returns null."); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after a disable." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after a disable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after disabling of non-top item." + ); + } + + // Re-enable the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.enable( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Enabling non-top item for a key returns null."); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after an enable." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after an enable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after enabling of non-top item." + ); + } + + // Remove the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.removeSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Removing non-top item for a key returns null."); + expectedKeys = expectedKeys.filter(expectedKey => expectedKey != key); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after a removal." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after a removal." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after removal of non-top item." + ); + ok( + !ExtensionSettingsStore.hasSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ), + "hasSetting returns the correct value when an extension does not have a setting set." + ); + } + + for (let key of KEY_LIST) { + // Disable the top item for a key. + let item = await ExtensionSettingsStore.disable( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][1], + "Disabling top item for a key returns the new top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item after a disable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after disabling of top item." + ); + + // Re-enable the top item for a key. + item = await ExtensionSettingsStore.enable( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][2], + "Re-enabling top item for a key returns the old top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after an enable." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after re-enabling top item." + ); + + // Remove the top item for a key. + item = await ExtensionSettingsStore.removeSetting( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][1], + "Removing top item for a key returns the new top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item after a removal." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after removal of top item." + ); + + // Add a setting for the current top item. + let itemToAdd = { key, value: `new-${key}`, id: "@second" }; + item = await ExtensionSettingsStore.addSetting( + extensions[1].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Updating top item for a key returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item after updating." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after updating." + ); + + // Disable the last remaining item for a key. + let expectedItem = { key, initialValue: initialValue(key) }; + // We're using the callback to set the expected value, so we need to increment the + // expectedCallbackCount. + expectedCallbackCount++; + item = await ExtensionSettingsStore.disable( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + expectedItem, + "Disabling last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + expectedItem, + "getSetting returns the initial value after all are disabled." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after all are disabled." + ); + + // Re-enable the last remaining item for a key. + item = await ExtensionSettingsStore.enable( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + itemToAdd, + "Re-enabling last item for a key returns the old value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns expected value after re-enabling." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after re-enabling." + ); + + // Remove the last remaining item for a key. + item = await ExtensionSettingsStore.removeSetting( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + expectedItem, + "Removing last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual(item, null, "getSetting returns null after all are removed."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after all are removed." + ); + + // Attempting to remove a setting that has had all extensions removed should *not* throw an exception. + removeResult = await ExtensionSettingsStore.removeSetting( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + removeResult, + null, + "Removing a setting that has had all extensions removed returns null." + ); + } + + // Test adding a setting with a value in callbackArgument. + let extensionIndex = 0; + let testKey = "callbackArgumentKey"; + let callbackArgumentValue = Date.now(); + // Add the setting. + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + testKey, + 1, + initialValue, + callbackArgumentValue + ); + expectedCallbackCount++; + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + // Remove the setting which should return the initial value. + let expectedItem = { + key: testKey, + initialValue: initialValue(callbackArgumentValue), + }; + // We're using the callback to set the expected value, so we need to increment the + // expectedCallbackCount. + expectedCallbackCount++; + item = await ExtensionSettingsStore.removeSetting( + extensions[extensionIndex].id, + TEST_TYPE, + testKey + ); + deepEqual( + item, + expectedItem, + "Removing last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, testKey); + deepEqual(item, null, "getSetting returns null after all are removed."); + + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, "not a key"); + equal( + item, + null, + "getSetting returns a null item if the setting does not have any records." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + "not a key" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl if the setting does not have any records." + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_store_setByUser() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@second" } }, + }, + }), + ]; + + let type = "some_type"; + let key = "some_key"; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let [one, two] = testExtensions.map(extension => extension.extension); + let initialCallback = () => "initial"; + + // Initialize the SettingsStore. + await ExtensionSettingsStore.initialize(); + + equal( + null, + ExtensionSettingsStore.getSetting(type, key), + "getSetting is initially null" + ); + + let item = await ExtensionSettingsStore.addSetting( + one.id, + type, + key, + "one", + initialCallback + ); + deepEqual( + { key, value: "one", id: one.id }, + item, + "addSetting returns the first set item" + ); + + item = await ExtensionSettingsStore.addSetting( + two.id, + type, + key, + "two", + initialCallback + ); + deepEqual( + { key, value: "two", id: two.id }, + item, + "addSetting returns the second set item" + ); + + // a user-set selection reverts to precedence order when new + // extension sets the setting. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + let three = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@third" } }, + }, + }); + await three.startup(); + + item = await ExtensionSettingsStore.addSetting( + three.id, + type, + key, + "three", + initialCallback + ); + deepEqual( + { key, value: "three", id: three.id }, + item, + "addSetting returns the third set item" + ); + deepEqual( + item, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the third set item" + ); + + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + item = ExtensionSettingsStore.select(one.id, type, key); + deepEqual( + { key, value: "one", id: one.id }, + item, + "selecting an extension returns the first set item after enable" + ); + + // Disabling a selected item returns to precedence order + ExtensionSettingsStore.disable(one.id, type, key); + deepEqual( + { key, value: "three", id: three.id }, + ExtensionSettingsStore.getSetting(type, key), + "returning to precedence order sets the third set item" + ); + + // Test that disabling all then enabling one does not take over a user-set setting. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + ExtensionSettingsStore.disable(three.id, type, key); + ExtensionSettingsStore.disable(two.id, type, key); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after disabling all extensions" + ); + + ExtensionSettingsStore.enable(three.id, type, key); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after enabling one extension" + ); + + // Ensure that calling addSetting again will not reset a user-set value when + // the extension install date is older than the user-set date. + item = await ExtensionSettingsStore.addSetting( + three.id, + type, + key, + "three", + initialCallback + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after calling addSetting for old addon" + ); + + item = ExtensionSettingsStore.enable(three.id, type, key); + equal(undefined, item, "enabling the active item does not return an item"); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after enabling one extension" + ); + + ExtensionSettingsStore.removeSetting(three.id, type, key); + ExtensionSettingsStore.removeSetting(two.id, type, key); + ExtensionSettingsStore.removeSetting(one.id, type, key); + + equal( + null, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns null after removing all settings" + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_store_add_disabled() { + await promiseStartupManager(); + + let id = "@add-on-disable"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + await ExtensionSettingsStore.initialize(); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + ExtensionSettingsStore.disable(id, "foo", "bar"); + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, undefined, "The add-on is not in control"); + equal(item.initialValue, "not set", "The value is not set"); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_uninstall_remove() { + await promiseStartupManager(); + + let id = "@add-on-uninstall"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + await ExtensionSettingsStore.initialize(); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + await extension.unload(); + + await promiseShutdownManager(); + + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item, null, "The add-on setting was removed"); +}); + +add_task(async function test_exceptions() { + await ExtensionSettingsStore.initialize(); + + await Assert.rejects( + ExtensionSettingsStore.addSetting( + 1, + TEST_TYPE, + "key_not_a_function", + "val1", + "not a function" + ), + /initialValueCallback must be a function/, + "addSetting rejects with a callback that is not a function." + ); +}); + +add_task(async function test_get_all_settings() { + await promiseStartupManager(); + + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@second" } }, + }, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + await ExtensionSettingsStore.initialize(); + + let items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 0, "There are no addons controlling this setting yet"); + + await ExtensionSettingsStore.addSetting( + "@first", + "foo", + "bar", + "set", + () => "not set" + ); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 1, "The add-on setting has 1 addon trying to control it"); + + await ExtensionSettingsStore.addSetting( + "@second", + "foo", + "bar", + "setting", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, "@second", "The second add-on is in control"); + equal(item.value, "setting", "The second value is set"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal( + items.length, + 2, + "The add-on setting has 2 addons trying to control it" + ); + + await ExtensionSettingsStore.removeSetting("@first", "foo", "bar"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 1, "There is only 1 addon controlling this setting"); + + await ExtensionSettingsStore.removeSetting("@second", "foo", "bar"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal( + items.length, + 0, + "There is no longer any addon controlling this setting" + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js new file mode 100644 index 0000000000..8044a07c71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js @@ -0,0 +1,151 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS"; +const HISTOGRAM_KEYED = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS_BY_ADDONID"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_telemetry() { + function contentScript() { + browser.test.sendMessage("content-script-run"); + } + + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + let extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_end", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_end", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + clearHistograms(); + + let process = IS_OOP ? "content" : "parent"; + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + + await extension1.startup(); + let extensionId = extension1.extension.id; + + info(`Started extension with id ${extensionId}`); + + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram after startup: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension1.awaitMessage("content-script-run"); + await promiseTelemetryRecorded(HISTOGRAM, process, 1); + await promiseKeyedTelemetryRecorded(HISTOGRAM_KEYED, process, extensionId, 1); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + + await contentPage.close(); + await extension1.unload(); + + await extension2.startup(); + let extensionId2 = extension2.extension.id; + + info(`Started extension with id ${extensionId2}`); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `No new data recorded for histogram after extension2 startup: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram after extension2 startup: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + ok( + !(extensionId2 in getKeyedSnapshots(process)[HISTOGRAM_KEYED]), + `No data recorded for histogram after startup: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + + contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension2.awaitMessage("content-script-run"); + await promiseTelemetryRecorded(HISTOGRAM, process, 2); + await promiseKeyedTelemetryRecorded( + HISTOGRAM_KEYED, + process, + extensionId2, + 1 + ); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 2, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId2].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + + await contentPage.close(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js new file mode 100644 index 0000000000..5e995b3aa6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionTestCommon } = ChromeUtils.import( + "resource://testing-common/ExtensionTestCommon.jsm" +); + +add_task(async function extension_startup_early_error() { + const EXTENSION_ID = "@extension-with-package-error"; + let extension = ExtensionTestCommon.generate({ + manifest: { + applications: { gecko: { id: EXTENSION_ID } }, + }, + }); + + extension.initLocale = async function() { + // Simulate error that happens during startup. + extension.packagingError("dummy error"); + }; + + let startupPromise = extension.startup(); + + let policy = WebExtensionPolicy.getByID(EXTENSION_ID); + ok(policy, "WebExtensionPolicy instantiated at startup"); + let readyPromise = policy.readyPromise; + ok(readyPromise, "WebExtensionPolicy.readyPromise is set"); + + await Assert.rejects( + startupPromise, + /dummy error/, + "Extension with packaging error should fail to load" + ); + + Assert.equal( + WebExtensionPolicy.getByID(EXTENSION_ID), + null, + "WebExtensionPolicy should be unregistered" + ); + + Assert.equal( + await readyPromise, + null, + "policy.readyPromise should be resolved with null" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js new file mode 100644 index 0000000000..fe1fab4ea2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_EXTENSION_STARTUP_MS"; +const HISTOGRAM_KEYED = "WEBEXT_EXTENSION_STARTUP_MS_BY_ADDONID"; + +function processSnapshot(snapshot) { + return snapshot.sum > 0; +} + +function processKeyedSnapshot(snapshot) { + let res = {}; + for (let key of Object.keys(snapshot)) { + res[key] = snapshot[key].sum > 0; + } + return res; +} + +add_task(async function test_telemetry() { + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + let extension1 = ExtensionTestUtils.loadExtension({}); + let extension2 = ExtensionTestUtils.loadExtension({}); + + clearHistograms(); + + assertHistogramEmpty(HISTOGRAM); + assertKeyedHistogramEmpty(HISTOGRAM_KEYED); + + await extension1.startup(); + + assertHistogramSnapshot( + HISTOGRAM, + { processSnapshot, expectedValue: true }, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + }, + }, + `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}` + ); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = Services.telemetry.getKeyedHistogramById( + HISTOGRAM_KEYED + ); + let histogramSum = histogram.snapshot().sum; + let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum; + + await extension2.startup(); + + assertHistogramSnapshot( + HISTOGRAM, + { + processSnapshot: snapshot => snapshot.sum > histogramSum, + expectedValue: true, + }, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + [extension2.extension.id]: true, + }, + }, + `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}` + ); + + equal( + histogramKeyed.snapshot()[extension1.extension.id].sum, + histogramSumExt1, + `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}` + ); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js new file mode 100644 index 0000000000..c05188cd38 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js @@ -0,0 +1,193 @@ +"use strict"; + +const FILE_DUMMY_URL = Services.io.newFileURI( + do_get_file("data/dummy_page.html") +).spec; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// XHR/fetch from content script to the page itself is allowed. +add_task(async function content_script_xhr_to_self() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["file:///*"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": async () => { + let response = await fetch(document.URL); + browser.test.assertEq(200, response.status, "expected load"); + let responseText = await response.text(); + browser.test.assertTrue( + responseText.includes("<p>Page</p>"), + `expected file content in response of ${response.url}` + ); + + // Now with content.fetch: + response = await content.fetch(document.URL); + browser.test.assertEq(200, response.status, "expected load (content)"); + + browser.test.sendMessage("done"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL); + await extension.awaitMessage("done"); + await contentPage.close(); + + await extension.unload(); +}); + +// XHR/fetch for other file is not allowed, even with file://-permissions. +add_task(async function content_script_xhr_to_other_file_not_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["file:///*"], + content_scripts: [ + { + matches: ["file:///*"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": async () => { + let otherFileUrl = document.URL.replace( + "dummy_page.html", + "file_sample.html" + ); + let x = new XMLHttpRequest(); + x.open("GET", otherFileUrl); + await new Promise(resolve => { + x.onloadend = resolve; + x.send(); + }); + browser.test.assertEq(0, x.status, "expected error"); + browser.test.assertEq("", x.responseText, "request should fail"); + + // Now with content.XMLHttpRequest. + x = new content.XMLHttpRequest(); + x.open("GET", otherFileUrl); + x.onloadend = () => { + browser.test.assertEq(0, x.status, "expected error (content)"); + browser.test.sendMessage("done"); + }; + x.send(); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL); + await extension.awaitMessage("done"); + await contentPage.close(); + + await extension.unload(); +}); + +// "file://" permission does not grant access to files in the extension page. +add_task(async function file_access_from_extension_page_not_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["file:///*"], + description: FILE_DUMMY_URL, + }, + async background() { + const FILE_DUMMY_URL = browser.runtime.getManifest().description; + + await browser.test.assertRejects( + fetch(FILE_DUMMY_URL), + /NetworkError when attempting to fetch resource/, + "block request to file from background page despite file permission" + ); + + // Regression test for bug 1420296 . + await browser.test.assertRejects( + fetch(FILE_DUMMY_URL, { mode: "same-origin" }), + /NetworkError when attempting to fetch resource/, + "block request to file from background page despite 'same-origin' mode" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("done"); + + await extension.unload(); +}); + +// webRequest listeners should see subresource requests from file:-principals. +add_task(async function webRequest_script_request_from_file_principals() { + // Extension without file:-permission should not see the request. + let extensionWithoutFilePermission = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.net/", "webRequest"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail(`Unexpected request from ${details.originUrl}`); + }, + { urls: ["http://example.net/intercept_by_webRequest.js"] } + ); + }, + }); + + // Extension with <all_urls> (which matches the resource URL at example.net + // and the origin at file://*/*) can see the request. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>", "webRequest", "webRequestBlocking"], + web_accessible_resources: ["testDONE.html"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ originUrl }) => { + browser.test.assertTrue( + /^file:.*file_do_load_script_subresource.html/.test(originUrl), + `expected script to be loaded from a local file (${originUrl})` + ); + let redirectUrl = browser.runtime.getURL("testDONE.html"); + return { + redirectUrl: `data:text/javascript,location.href='${redirectUrl}';`, + }; + }, + { urls: ["http://example.net/intercept_by_webRequest.js"] }, + ["blocking"] + ); + }, + files: { + "testDONE.html": `<!DOCTYPE html><script src="testDONE.js"></script>`, + "testDONE.js"() { + browser.test.sendMessage("webRequest_redirect_completed"); + }, + }, + }); + + await extensionWithoutFilePermission.startup(); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + Services.io.newFileURI( + do_get_file("data/file_do_load_script_subresource.html") + ).spec + ); + await extension.awaitMessage("webRequest_redirect_completed"); + await contentPage.close(); + + await extension.unload(); + await extensionWithoutFilePermission.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js new file mode 100644 index 0000000000..69c24cfc4b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js @@ -0,0 +1,208 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +let getExtension = () => { + return ExtensionTestUtils.loadExtension({ + background: async function() { + const runningListener = isRunning => { + if (isRunning) { + browser.test.sendMessage("started"); + } else { + browser.test.sendMessage("stopped"); + } + }; + + browser.test.onMessage.addListener(async (message, data) => { + let result; + switch (message) { + case "start": + result = await browser.geckoProfiler.start({ + bufferSize: 10000, + windowLength: 20, + interval: 0.5, + features: ["js"], + threads: ["GeckoMain"], + }); + browser.test.assertEq(undefined, result, "start returns nothing."); + break; + case "stop": + result = await browser.geckoProfiler.stop(); + browser.test.assertEq(undefined, result, "stop returns nothing."); + break; + case "pause": + result = await browser.geckoProfiler.pause(); + browser.test.assertEq(undefined, result, "pause returns nothing."); + browser.test.sendMessage("paused"); + break; + case "resume": + result = await browser.geckoProfiler.resume(); + browser.test.assertEq(undefined, result, "resume returns nothing."); + browser.test.sendMessage("resumed"); + break; + case "test profile": + result = await browser.geckoProfiler.getProfile(); + browser.test.assertTrue( + "libs" in result, + "The profile contains libs." + ); + browser.test.assertTrue( + "meta" in result, + "The profile contains meta." + ); + browser.test.assertTrue( + "threads" in result, + "The profile contains threads." + ); + browser.test.assertTrue( + result.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); + browser.test.sendMessage("tested profile"); + break; + case "test dump to file": + try { + await browser.geckoProfiler.dumpProfileToFile(data.fileName); + browser.test.sendMessage("tested dump to file", {}); + } catch (e) { + browser.test.sendMessage("tested dump to file", { + error: e.message, + }); + } + break; + case "test profile as array buffer": + let arrayBuffer = await browser.geckoProfiler.getProfileAsArrayBuffer(); + browser.test.assertTrue( + arrayBuffer.byteLength >= 2, + "The profile array buffer contains data." + ); + let textDecoder = new TextDecoder(); + let profile = JSON.parse(textDecoder.decode(arrayBuffer)); + browser.test.assertTrue( + "libs" in profile, + "The profile contains libs." + ); + browser.test.assertTrue( + "meta" in profile, + "The profile contains meta." + ); + browser.test.assertTrue( + "threads" in profile, + "The profile contains threads." + ); + browser.test.assertTrue( + profile.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); + browser.test.sendMessage("tested profile as array buffer"); + break; + case "remove runningListener": + browser.geckoProfiler.onRunning.removeListener(runningListener); + browser.test.sendMessage("removed runningListener"); + break; + } + }); + + browser.test.sendMessage("ready"); + + browser.geckoProfiler.onRunning.addListener(runningListener); + }, + + manifest: { + permissions: ["geckoProfiler"], + applications: { + gecko: { + id: "profilertest@mozilla.com", + }, + }, + }, + }); +}; + +let verifyProfileData = bytes => { + let textDecoder = new TextDecoder(); + let profile = JSON.parse(textDecoder.decode(bytes)); + ok("libs" in profile, "The profile contains libs."); + ok("meta" in profile, "The profile contains meta."); + ok("threads" in profile, "The profile contains threads."); + ok( + profile.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); +}; + +add_task(async function testProfilerControl() { + const acceptedExtensionIdsPref = + "extensions.geckoProfiler.acceptedExtensionIds"; + Services.prefs.setCharPref( + acceptedExtensionIdsPref, + "profilertest@mozilla.com" + ); + + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + await extension.awaitMessage("stopped"); + + extension.sendMessage("start"); + await extension.awaitMessage("started"); + + extension.sendMessage("test profile"); + await extension.awaitMessage("tested profile"); + + const profilerPath = OS.Path.join(OS.Constants.Path.profileDir, "profiler"); + let data, fileName, targetPath; + + // test with file name only + fileName = "bar.profile"; + targetPath = OS.Path.join(profilerPath, fileName); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, undefined, "No error thrown"); + ok(await OS.File.exists(targetPath), "Saved gecko profile exists."); + verifyProfileData(await OS.File.read(targetPath)); + + // test overwriting the formerly created file + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, undefined, "No error thrown"); + ok(await OS.File.exists(targetPath), "Saved gecko profile exists."); + verifyProfileData(await OS.File.read(targetPath)); + + // test with a POSIX path, which is not allowed + fileName = "foo/bar.profile"; + targetPath = OS.Path.join(profilerPath, ...fileName.split("/")); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, "Path cannot contain a subdirectory."); + ok(!(await OS.File.exists(targetPath)), "Gecko profile hasn't been saved."); + + // test with a non POSIX path which is not allowed + fileName = "foo\\bar.profile"; + targetPath = OS.Path.join(profilerPath, ...fileName.split("\\")); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, "Path cannot contain a subdirectory."); + ok(!(await OS.File.exists(targetPath)), "Gecko profile hasn't been saved."); + + extension.sendMessage("test profile as array buffer"); + await extension.awaitMessage("tested profile as array buffer"); + + extension.sendMessage("pause"); + await extension.awaitMessage("paused"); + + extension.sendMessage("resume"); + await extension.awaitMessage("resumed"); + + extension.sendMessage("stop"); + await extension.awaitMessage("stopped"); + + extension.sendMessage("remove runningListener"); + await extension.awaitMessage("removed runningListener"); + + await extension.unload(); + + Services.prefs.clearUserPref(acceptedExtensionIdsPref); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js new file mode 100644 index 0000000000..d0bbf7e60f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js @@ -0,0 +1,56 @@ +"use strict"; + +add_task(async function() { + const acceptedExtensionIdsPref = + "extensions.geckoProfiler.acceptedExtensionIds"; + Services.prefs.setCharPref( + acceptedExtensionIdsPref, + "profilertest@mozilla.com" + ); + + let extension = ExtensionTestUtils.loadExtension({ + background: () => { + browser.test.sendMessage( + "features", + Object.values(browser.geckoProfiler.ProfilerFeature) + ); + }, + manifest: { + permissions: ["geckoProfiler"], + applications: { + gecko: { + id: "profilertest@mozilla.com", + }, + }, + }, + }); + + await extension.startup(); + let acceptedFeatures = await extension.awaitMessage("features"); + await extension.unload(); + + Services.prefs.clearUserPref(acceptedExtensionIdsPref); + + const allFeaturesAcceptedByProfiler = Services.profiler.GetAllFeatures(); + ok( + allFeaturesAcceptedByProfiler.length >= 2, + "Either we've massively reduced the profiler's feature set, or something is wrong." + ); + + // Check that the list of available values in the ProfilerFeature enum + // matches the list of features supported by the profiler. + for (const feature of allFeaturesAcceptedByProfiler) { + ok( + acceptedFeatures.includes(feature), + `The schema of the geckoProfiler.start() method should accept the "${feature}" feature.` + ); + } + for (const feature of acceptedFeatures) { + ok( + // Bug 1594566 - ignore Responsiveness until the extension is updated + allFeaturesAcceptedByProfiler.includes(feature) || + feature == "responsiveness", + `The schema of the geckoProfiler.start() method mentions a "${feature}" feature which is not supported by the profiler.` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js new file mode 100644 index 0000000000..63b1016293 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js @@ -0,0 +1,61 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessage.addListener(([url1, url2]) => { + let url3 = browser.runtime.getURL("test_file.html"); + let url4 = browser.extension.getURL("test_file.html"); + + browser.test.assertTrue(url1 !== undefined, "url1 defined"); + + browser.test.assertTrue( + url1.startsWith("moz-extension://"), + "url1 has correct scheme" + ); + browser.test.assertTrue( + url1.endsWith("test_file.html"), + "url1 has correct leaf name" + ); + + browser.test.assertEq(url1, url2, "url2 matches"); + browser.test.assertEq(url1, url3, "url3 matches"); + browser.test.assertEq(url1, url4, "url4 matches"); + + browser.test.notifyPass("geturl"); + }); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + + files: { + "content_script.js"() { + let url1 = browser.runtime.getURL("test_file.html"); + let url2 = browser.extension.getURL("test_file.html"); + browser.runtime.sendMessage([url1, url2]); + }, + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("geturl"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js new file mode 100644 index 0000000000..9709df842d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js @@ -0,0 +1,574 @@ +"use strict"; + +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +var originalReqLocales = Services.locale.requestedLocales; + +registerCleanupFunction(() => { + Preferences.reset("intl.accept_languages"); + Services.locale.requestedLocales = originalReqLocales; +}); + +add_task(async function test_i18n() { + function runTests(assertEq) { + let _ = browser.i18n.getMessage.bind(browser.i18n); + + let url = browser.runtime.getURL("/"); + assertEq( + url, + `moz-extension://${_("@@extension_id")}/`, + "@@extension_id builtin message" + ); + + assertEq("Foo.", _("Foo"), "Simple message in selected locale."); + + assertEq("(bar)", _("bar"), "Simple message fallback in default locale."); + + assertEq("", _("some-unknown-locale-string"), "Unknown locale string."); + + assertEq("", _("@@unknown_builtin_string"), "Unknown built-in string."); + assertEq( + "", + _("@@bidi_unknown_builtin_string"), + "Unknown built-in bidi string." + ); + + assertEq("Føo.", _("Föo"), "Multi-byte message in selected locale."); + + let substitutions = []; + substitutions[4] = "5"; + substitutions[13] = "14"; + + assertEq( + "'$0' '14' '' '5' '$$$$' '$'.", + _("basic_substitutions", substitutions), + "Basic numeric substitutions" + ); + + assertEq( + "'$0' '' 'just a string' '' '$$$$' '$'.", + _("basic_substitutions", "just a string"), + "Basic numeric substitutions, with non-array value" + ); + + let values = _("named_placeholder_substitutions", [ + "(subst $1 $2)", + "(2 $1 $2)", + ]).split("\n"); + + assertEq( + "_foo_ (subst $1 $2) _bar_", + values[0], + "Named and numeric substitution" + ); + + assertEq( + "(2 $1 $2)", + values[1], + "Numeric substitution amid named placeholders" + ); + + assertEq("$bad name$", values[2], "Named placeholder with invalid key"); + + assertEq("", values[3], "Named placeholder with an invalid value"); + + assertEq( + "Accepted, but shouldn't break.", + values[4], + "Named placeholder with a strange content value" + ); + + assertEq("$foo", values[5], "Non-placeholder token that should be ignored"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + default_locale: "jp", + + content_scripts: [ + { matches: ["http://*/*/file_sample.html"], js: ["content.js"] }, + ], + }, + + files: { + "_locales/en_US/messages.json": { + foo: { + message: "Foo.", + description: "foo", + }, + + föo: { + message: "Føo.", + description: "foo", + }, + + basic_substitutions: { + message: "'$0' '$14' '$1' '$5' '$$$$$' '$$'.", + description: "foo", + }, + + Named_placeholder_substitutions: { + message: + "$Foo$\n$2\n$bad name$\n$bad_value$\n$bad_content_value$\n$foo", + description: "foo", + placeholders: { + foO: { + content: "_foo_ $1 _bar_", + description: "foo", + }, + + "bad name": { + content: "Nope.", + description: "bad name", + }, + + bad_value: "Nope.", + + bad_content_value: { + content: ["Accepted, but shouldn't break."], + description: "bad value", + }, + }, + }, + + broken_placeholders: { + message: "$broken$", + description: "broken placeholders", + placeholders: "foo.", + }, + }, + + "_locales/jp/messages.json": { + foo: { + message: "(foo)", + description: "foo", + }, + + bar: { + message: "(bar)", + description: "bar", + }, + }, + + "content.js": + "new " + + function(runTestsFn) { + runTestsFn((...args) => { + browser.runtime.sendMessage(["assertEq", ...args]); + }); + + browser.runtime.sendMessage(["content-script-finished"]); + } + + `(${runTests})`, + }, + + background: + "new " + + function(runTestsFn) { + browser.runtime.onMessage.addListener(([msg, ...args]) => { + if (msg == "assertEq") { + browser.test.assertEq(...args); + } else { + browser.test.sendMessage(msg, ...args); + } + }); + + runTestsFn(browser.test.assertEq.bind(browser.test)); + } + + `(${runTests})`, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension.awaitMessage("content-script-finished"); + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_i18n_negotiation() { + function runTests(expected) { + let _ = browser.i18n.getMessage.bind(browser.i18n); + + browser.test.assertEq(expected, _("foo"), "Got expected message"); + } + + let extensionData = { + manifest: { + default_locale: "en_US", + + content_scripts: [ + { matches: ["http://*/*/file_sample.html"], js: ["content.js"] }, + ], + }, + + files: { + "_locales/en_US/messages.json": { + foo: { + message: "English.", + description: "foo", + }, + }, + + "_locales/jp/messages.json": { + foo: { + message: "\u65e5\u672c\u8a9e", + description: "foo", + }, + }, + + "content.js": + "new " + + function(runTestsFn) { + browser.test.onMessage.addListener(expected => { + runTestsFn(expected); + + browser.test.sendMessage("content-script-finished"); + }); + browser.test.sendMessage("content-ready"); + } + + `(${runTests})`, + }, + + background: + "new " + + function(runTestsFn) { + browser.test.onMessage.addListener(expected => { + runTestsFn(expected); + + browser.test.sendMessage("background-script-finished"); + }); + } + + `(${runTests})`, + }; + + // At the moment extension language negotiation is tied to Firefox language + // negotiation result. That means that to test an extension in `fr`, we need + // to mock `fr` being available in Firefox and then request it. + // + // In the future, we should provide some way for tests to decouple their + // language selection from that of Firefox. + Services.locale.availableLocales = ["en-US", "fr", "jp"]; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + for (let [lang, msg] of [ + ["en-US", "English."], + ["jp", "\u65e5\u672c\u8a9e"], + ]) { + Services.locale.requestedLocales = [lang]; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("content-ready"); + + extension.sendMessage(msg); + await extension.awaitMessage("background-script-finished"); + await extension.awaitMessage("content-script-finished"); + + await extension.unload(); + } + Services.locale.requestedLocales = originalReqLocales; + + await contentPage.close(); +}); + +add_task(async function test_get_accept_languages() { + function checkResults(source, results, expected) { + browser.test.assertEq( + expected.length, + results.length, + `got expected number of languages in ${source}` + ); + results.forEach((lang, index) => { + browser.test.assertEq( + expected[index], + lang, + `got expected language in ${source}` + ); + }); + } + + function background(checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.getAcceptLanguages().then(results => { + checkResultsFn("background", results, expected); + + browser.test.sendMessage("background-done"); + }); + }); + } + + function content(checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.getAcceptLanguages().then(results => { + checkResultsFn("contentScript", results, expected); + + browser.test.sendMessage("content-done"); + }); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${background})(${checkResults})`, + + files: { + "content_script.js": `(${content})(${checkResults})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + let expectedLangs = ["en-US", "en"]; + extension.sendMessage(["expect-results", expectedLangs]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + expectedLangs = ["en-US", "en", "fr-CA", "fr"]; + Preferences.set("intl.accept_languages", expectedLangs.toString()); + extension.sendMessage(["expect-results", expectedLangs]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + Preferences.reset("intl.accept_languages"); + + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_get_ui_language() { + function getResults() { + return { + getUILanguage: browser.i18n.getUILanguage(), + getMessage: browser.i18n.getMessage("@@ui_locale"), + }; + } + + function checkResults(source, results, expected) { + browser.test.assertEq( + expected, + results.getUILanguage, + `Got expected getUILanguage result in ${source}` + ); + browser.test.assertEq( + expected, + results.getMessage, + `Got expected getMessage result in ${source}` + ); + } + + function background(getResultsFn, checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + checkResultsFn("background", getResultsFn(), expected); + + browser.test.sendMessage("background-done"); + }); + } + + function content(getResultsFn, checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + checkResultsFn("contentScript", getResultsFn(), expected); + + browser.test.sendMessage("content-done"); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${background})(${getResults}, ${checkResults})`, + + files: { + "content_script.js": `(${content})(${getResults}, ${checkResults})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + extension.sendMessage(["expect-results", "en-US"]); + + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + // We don't currently have a good way to mock this. + if (false) { + Services.locale.requestedLocales = ["he"]; + + extension.sendMessage(["expect-results", "he"]); + + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + } + + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_detect_language() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // This is not supported on Android. + return; + } + + const af_string = + " aam skukuza die naam beteken hy wat skoonvee of hy wat alles onderstebo keer wysig " + + "bosveldkampe boskampe is kleiner afgeleë ruskampe wat oor min fasiliteite beskik daar is geen restaurante " + + "of winkels nie en slegs oornagbesoekers word toegelaat bateleur"; + // String with intermixed French/English text + const fr_en_string = + "France is the largest country in Western Europe and the third-largest in Europe as a whole. " + + "A accès aux chiens et aux frontaux qui lui ont été il peut consulter et modifier ses collections et exporter " + + "Cet article concerne le pays européen aujourd’hui appelé République française. Pour d’autres usages du nom France, " + + "Pour une aide rapide et effective, veuiller trouver votre aide dans le menu ci-dessus." + + "Motoring events began soon after the construction of the first successful gasoline-fueled automobiles. The quick brown fox jumped over the lazy dog"; + + function checkResult(source, result, expected) { + browser.test.assertEq( + expected.isReliable, + result.isReliable, + "result.confident is true" + ); + browser.test.assertEq( + expected.languages.length, + result.languages.length, + `result.languages contains the expected number of languages in ${source}` + ); + expected.languages.forEach((lang, index) => { + browser.test.assertEq( + lang.percentage, + result.languages[index].percentage, + `element ${index} of result.languages array has the expected percentage in ${source}` + ); + browser.test.assertEq( + lang.language, + result.languages[index].language, + `element ${index} of result.languages array has the expected language in ${source}` + ); + }); + } + + function backgroundScript(checkResultFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.detectLanguage(msg).then(result => { + checkResultFn("background", result, expected); + browser.test.sendMessage("background-done"); + }); + }); + } + + function content(checkResultFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.detectLanguage(msg).then(result => { + checkResultFn("contentScript", result, expected); + browser.test.sendMessage("content-done"); + }); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${backgroundScript})(${checkResult})`, + + files: { + "content_script.js": `(${content})(${checkResult})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + let expected = { + isReliable: true, + languages: [ + { + language: "fr", + percentage: 67, + }, + { + language: "en", + percentage: 32, + }, + ], + }; + extension.sendMessage([fr_en_string, expected]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + expected = { + isReliable: true, + languages: [ + { + language: "af", + percentage: 99, + }, + ], + }; + extension.sendMessage([af_string, expected]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js new file mode 100644 index 0000000000..c644ba9782 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js @@ -0,0 +1,197 @@ +"use strict"; + +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// Some multibyte characters. This sample was taken from the encoding/api-basics.html web platform test. +const MULTIBYTE_STRING = "z\xA2\u6C34\uD834\uDD1E\uF8FF\uDBFF\uDFFD\uFFFE"; +let getCSS = (a, b) => `a { content: '${a}'; } b { content: '${b}'; }`; + +let extensionData = { + background: function() { + function backgroundFetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.overrideMimeType("text/plain"); + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = reject; + xhr.send(); + }); + } + + Promise.all([ + backgroundFetch("foo.css"), + backgroundFetch("bar.CsS?x#y"), + backgroundFetch("foo.txt"), + ]).then(results => { + browser.test.assertEq( + "body { max-width: 42px; }", + results[0], + "CSS file localized" + ); + browser.test.assertEq( + "body { max-width: 42px; }", + results[1], + "CSS file localized" + ); + + browser.test.assertEq( + "body { __MSG_foo__; }", + results[2], + "Text file not localized" + ); + + browser.test.notifyPass("i18n-css"); + }); + + browser.test.sendMessage("ready", browser.runtime.getURL("/")); + }, + + manifest: { + applications: { + gecko: { + id: "i18n_css@mochi.test", + }, + }, + + web_accessible_resources: [ + "foo.css", + "foo.txt", + "locale.css", + "multibyte.css", + ], + + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + css: ["foo.css"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content.js"], + }, + ], + + default_locale: "en", + }, + + files: { + "_locales/en/messages.json": JSON.stringify({ + foo: { + message: "max-width: 42px", + description: "foo", + }, + multibyteKey: { + message: MULTIBYTE_STRING, + }, + }), + + "content.js": function() { + let style = getComputedStyle(document.body); + browser.test.sendMessage("content-maxWidth", style.maxWidth); + }, + + "foo.css": "body { __MSG_foo__; }", + "bar.CsS": "body { __MSG_foo__; }", + "foo.txt": "body { __MSG_foo__; }", + "locale.css": + '* { content: "__MSG_@@ui_locale__ __MSG_@@bidi_dir__ __MSG_@@bidi_reversed_dir__ __MSG_@@bidi_start_edge__ __MSG_@@bidi_end_edge__" }', + "multibyte.css": getCSS("__MSG_multibyteKey__", MULTIBYTE_STRING), + }, +}; + +async function test_i18n_css(options = {}) { + extensionData.useAddonManager = options.useAddonManager; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let baseURL = await extension.awaitMessage("ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + let css = await contentPage.fetch(baseURL + "foo.css"); + + equal( + css, + "body { max-width: 42px; }", + "CSS file localized in mochitest scope" + ); + + let maxWidth = await extension.awaitMessage("content-maxWidth"); + + equal(maxWidth, "42px", "stylesheet correctly applied"); + + css = await contentPage.fetch(baseURL + "locale.css"); + equal( + css, + '* { content: "en-US ltr rtl left right" }', + "CSS file localized in mochitest scope" + ); + + css = await contentPage.fetch(baseURL + "multibyte.css"); + equal( + css, + getCSS(MULTIBYTE_STRING, MULTIBYTE_STRING), + "CSS file contains multibyte string" + ); + + await contentPage.close(); + + // We don't currently have a good way to mock this. + if (false) { + const DIR = "intl.l10n.pseudo"; + + // We don't wind up actually switching the chrome registry locale, since we + // don't have a chrome package for Hebrew. So just override it, and force + // RTL directionality. + const origReqLocales = Services.locale.requestedLocales; + Services.locale.requestedLocales = ["he"]; + Preferences.set(DIR, "bidi"); + + css = await fetch(baseURL + "locale.css"); + equal( + css, + '* { content: "he rtl ltr right left" }', + "CSS file localized in mochitest scope" + ); + + Services.locale.requestedLocales = origReqLocales; + Preferences.reset(DIR); + } + + await extension.awaitFinish("i18n-css"); + await extension.unload(); +} + +add_task(async function startup() { + await promiseStartupManager(); +}); +add_task(test_i18n_css); +add_task(async function test_i18n_css_xpi() { + await test_i18n_css({ useAddonManager: "temporary" }); +}); +add_task(async function startup() { + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js new file mode 100644 index 0000000000..8225278a7f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js @@ -0,0 +1,270 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { MockRegistrar } = ChromeUtils.import( + "resource://testing-common/MockRegistrar.jsm" +); + +let idleService = { + _observers: new Set(), + _activity: { + addCalls: [], + removeCalls: [], + observerFires: [], + }, + _reset: function() { + this._observers.clear(); + this._activity.addCalls = []; + this._activity.removeCalls = []; + this._activity.observerFires = []; + }, + _fireObservers: function(state) { + for (let observer of this._observers.values()) { + observer.observe(observer, state, null); + this._activity.observerFires.push(state); + } + }, + QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]), + idleTime: 19999, + addIdleObserver: function(observer, time) { + this._observers.add(observer); + this._activity.addCalls.push(time); + }, + removeIdleObserver: function(observer, time) { + this._observers.delete(observer); + this._activity.removeCalls.push(time); + }, +}; + +function checkActivity(expectedActivity) { + let { expectedAdd, expectedRemove, expectedFires } = expectedActivity; + let { addCalls, removeCalls, observerFires } = idleService._activity; + equal( + expectedAdd.length, + addCalls.length, + "idleService.addIdleObserver was called the expected number of times" + ); + equal( + expectedRemove.length, + removeCalls.length, + "idleService.removeIdleObserver was called the expected number of times" + ); + equal( + expectedFires.length, + observerFires.length, + "idle observer was fired the expected number of times" + ); + deepEqual( + addCalls, + expectedAdd, + "expected interval passed to idleService.addIdleObserver" + ); + deepEqual( + removeCalls, + expectedRemove, + "expected interval passed to idleService.removeIdleObserver" + ); + deepEqual( + observerFires, + expectedFires, + "expected topic passed to idle observer" + ); +} + +add_task(async function setup() { + let fakeIdleService = MockRegistrar.register( + "@mozilla.org/widget/useridleservice;1", + idleService + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakeIdleService); + }); +}); + +add_task(async function testQueryStateActive() { + function background() { + browser.idle.queryState(20).then( + status => { + browser.test.assertEq("active", status, "Idle status is active"); + browser.test.notifyPass("idle"); + }, + err => { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("idle"); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +add_task(async function testQueryStateIdle() { + function background() { + browser.idle.queryState(15).then( + status => { + browser.test.assertEq("idle", status, "Idle status is idle"); + browser.test.notifyPass("idle"); + }, + err => { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("idle"); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +add_task(async function testOnlySetDetectionInterval() { + function background() { + browser.idle.setDetectionInterval(99); + browser.test.sendMessage("detectionIntervalSet"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("detectionIntervalSet"); + idleService._fireObservers("idle"); + checkActivity({ expectedAdd: [], expectedRemove: [], expectedFires: [] }); + await extension.unload(); +}); + +add_task(async function testSetDetectionIntervalBeforeAddingListener() { + function background() { + browser.idle.setDetectionInterval(99); + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "idle", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("listenerAdded"); + idleService._fireObservers("idle"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [99], + expectedRemove: [], + expectedFires: ["idle"], + }); + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function testSetDetectionIntervalAfterAddingListener() { + function background() { + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "idle", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.idle.setDetectionInterval(99); + browser.test.sendMessage("detectionIntervalSet"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("detectionIntervalSet"); + idleService._fireObservers("idle"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [60, 99], + expectedRemove: [60], + expectedFires: ["idle"], + }); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function testOnlyAddingListener() { + function background() { + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "active", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("listenerAdded"); + idleService._fireObservers("active"); + await extension.awaitMessage("listenerFired"); + // check that "idle-daily" topic does not cause a listener to fire + idleService._fireObservers("idle-daily"); + checkActivity({ + expectedAdd: [60], + expectedRemove: [], + expectedFires: ["active", "idle-daily"], + }); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js new file mode 100644 index 0000000000..9b17a633e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js @@ -0,0 +1,302 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); + +// Assert on the expected "addonsManager.action" telemetry events (and optional filter events to verify +// by using a given actionType). +function assertActionAMTelemetryEvent( + expectedActionEvents, + assertMessage, + { actionType } = {} +) { + const snapshot = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ); + + ok( + snapshot.parent && !!snapshot.parent.length, + "Got parent telemetry events in the snapshot" + ); + + const events = snapshot.parent + .filter(([timestamp, category, method, object, value, extra]) => { + return ( + category === "addonsManager" && + method === "action" && + (!actionType ? true : extra && extra.action === actionType) + ); + }) + .map(([timestamp, category, method, object, value, extra]) => { + return { method, object, value, extra }; + }); + + Assert.deepEqual(events, expectedActionEvents, assertMessage); +} + +async function runIncognitoTest( + extensionData, + privateBrowsingAllowed, + allowPrivateBrowsingByDefault +) { + Services.prefs.setBoolPref( + "extensions.allowPrivateBrowsingByDefault", + allowPrivateBrowsingByDefault + ); + + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + let { extension } = wrapper; + + if (!allowPrivateBrowsingByDefault) { + // Check the permission if we're not allowPrivateBrowsingByDefault. + equal( + extension.permissions.has("internal:privateBrowsingAllowed"), + privateBrowsingAllowed, + "privateBrowsingAllowed in serialized extension" + ); + } + equal( + extension.privateBrowsingAllowed, + privateBrowsingAllowed, + "privateBrowsingAllowed in extension" + ); + equal( + extension.policy.privateBrowsingAllowed, + privateBrowsingAllowed, + "privateBrowsingAllowed on policy" + ); + + await wrapper.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +} + +add_task(async function test_extension_incognito_spanning() { + await runIncognitoTest({}, false, false); + await runIncognitoTest({}, true, true); +}); + +// Test that when we are restricted, we can override the restriction for tests. +add_task(async function test_extension_incognito_override_spanning() { + let extensionData = { + incognitoOverride: "spanning", + }; + await runIncognitoTest(extensionData, true, false); +}); + +// This tests that a privileged extension will always have private browsing. +add_task(async function test_extension_incognito_privileged() { + let extensionData = { + isPrivileged: true, + }; + await runIncognitoTest(extensionData, true, true); + await runIncognitoTest(extensionData, true, false); +}); + +// We only test spanning upgrades since that is the only allowed +// incognito type prior to feature being turned on. +add_task(async function test_extension_incognito_spanning_grandfathered() { + await AddonTestUtils.promiseStartupManager(); + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", true); + Services.prefs.setBoolPref("extensions.incognito.migrated", false); + + // This extension gets disabled before the "upgrade", it should not + // get grandfathered permissions. + const disabledAddonId = "disabled-ext@mozilla.com"; + let disabledWrapper = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: disabledAddonId } }, + incognito: "spanning", + }, + useAddonManager: "permanent", + }); + await disabledWrapper.startup(); + let disabledPolicy = WebExtensionPolicy.getByID(disabledAddonId); + + // Verify policy settings. + equal( + disabledPolicy.permissions.includes("internal:privateBrowsingAllowed"), + false, + "privateBrowsingAllowed is not in permissions for disabled addon" + ); + equal( + disabledPolicy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in disabled addon" + ); + + let disabledAddon = await AddonManager.getAddonByID(disabledAddonId); + await disabledAddon.disable(); + + // This extension gets grandfathered permissions for private browsing. + let addonId = "grandfathered@mozilla.com"; + let wrapper = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: addonId } }, + incognito: "spanning", + }, + useAddonManager: "permanent", + }); + await wrapper.startup(); + let policy = WebExtensionPolicy.getByID(addonId); + + // Verify policy settings. + equal( + policy.permissions.includes("internal:privateBrowsingAllowed"), + false, + "privateBrowsingAllowed is not in permissions" + ); + equal( + policy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in extension" + ); + + // Turn on incognito support and update the browser. + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + // Disable the addonsManager telemetry event category, to ensure that it will + // be enabled automatically during the AddonManager/XPIProvider startup and + // the telemetry event recorded (See Bug 1540112 for a rationale). + Services.telemetry.setEventRecordingEnabled("addonsManager", false); + await AddonTestUtils.promiseRestartManager("2"); + await wrapper.awaitStartup(); + + // Did it upgrade? + ok( + Services.prefs.getBoolPref("extensions.incognito.migrated", false), + "pref marked as migrated" + ); + + // Verify policy settings. + policy = WebExtensionPolicy.getByID(addonId); + ok( + policy.permissions.includes("internal:privateBrowsingAllowed"), + "privateBrowsingAllowed is in permissions" + ); + equal( + policy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in extension" + ); + + // Verify the disabled addon did not get permissions. + disabledAddon = await AddonManager.getAddonByID(disabledAddonId); + await disabledAddon.enable(); + disabledPolicy = WebExtensionPolicy.getByID(disabledAddonId); + + // Verify policy settings. + equal( + disabledPolicy.permissions.includes("internal:privateBrowsingAllowed"), + false, + "privateBrowsingAllowed is not in permissions for disabled addon" + ); + equal( + disabledPolicy.privateBrowsingAllowed, + false, + "privateBrowsingAllowed in disabled addon" + ); + + await wrapper.unload(); + await disabledWrapper.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); + Services.prefs.clearUserPref("extensions.incognito.migrated"); + + const expectedEvents = [ + { + method: "action", + object: "appUpgrade", + value: "on", + extra: { addonId, action: "privateBrowsingAllowed" }, + }, + ]; + + assertActionAMTelemetryEvent( + expectedEvents, + "Got the expected telemetry events for the grandfathered extensions", + { actionType: "privateBrowsingAllowed" } + ); +}); + +add_task(async function test_extension_privileged_not_allowed() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + let addonId = "privileged_not_allowed@mochi.test"; + let extensionData = { + manifest: { + version: "1.0", + applications: { gecko: { id: addonId } }, + incognito: "not_allowed", + }, + useAddonManager: "permanent", + isPrivileged: true, + }; + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + let policy = WebExtensionPolicy.getByID(addonId); + equal( + policy.extension.isPrivileged, + true, + "The test extension is privileged" + ); + equal( + policy.privateBrowsingAllowed, + false, + "privateBrowsingAllowed is false" + ); + + await wrapper.unload(); +}); + +// Test that we remove pb permission if an extension is updated to not_allowed. +add_task(async function test_extension_upgrade_not_allowed() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + let addonId = "upgrade@mochi.test"; + let extensionData = { + manifest: { + version: "1.0", + applications: { gecko: { id: addonId } }, + incognito: "spanning", + }, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }; + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + + let policy = WebExtensionPolicy.getByID(addonId); + + equal( + policy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in extension" + ); + + extensionData.manifest.version = "2.0"; + extensionData.manifest.incognito = "not_allowed"; + await wrapper.upgrade(extensionData); + + equal(wrapper.version, "2.0", "Expected extension version"); + policy = WebExtensionPolicy.getByID(addonId); + equal( + policy.privateBrowsingAllowed, + false, + "privateBrowsingAllowed is false" + ); + + await wrapper.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js new file mode 100644 index 0000000000..e520c48f26 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js @@ -0,0 +1,101 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function test_indexedDB_principal() { + Services.prefs.setBoolPref("privacy.firstparty.isolate", true); + + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + async background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "create-storage") { + let request = window.indexedDB.open("TestDatabase"); + request.onupgradeneeded = function(e) { + let db = e.target.result; + db.createObjectStore("TestStore"); + }; + request.onsuccess = function(e) { + let db = e.target.result; + let tx = db.transaction("TestStore", "readwrite"); + let store = tx.objectStore("TestStore"); + tx.oncomplete = () => browser.test.sendMessage("storage-created"); + store.add("foo", "bar"); + tx.onerror = function(e) { + browser.test.fail(`Failed with error ${tx.error.message}`); + // Don't wait for timeout + browser.test.sendMessage("storage-created"); + }; + }; + request.onerror = function(e) { + browser.test.fail(`Failed with error ${request.error.message}`); + // Don't wait for timeout + browser.test.sendMessage("storage-created"); + }; + return; + } + if (msg == "check-storage") { + let dbRequest = window.indexedDB.open("TestDatabase"); + dbRequest.onupgradeneeded = function() { + browser.test.fail("Database should exist"); + browser.test.notifyFail("done"); + }; + dbRequest.onsuccess = function(e) { + let db = e.target.result; + let transaction = db.transaction("TestStore"); + transaction.onerror = function(e) { + browser.test.fail( + `Failed with error ${transaction.error.message}` + ); + browser.test.notifyFail("done"); + }; + let objectStore = transaction.objectStore("TestStore"); + let request = objectStore.get("bar"); + request.onsuccess = function(event) { + browser.test.assertEq( + request.result, + "foo", + "Got the expected data" + ); + browser.test.notifyPass("done"); + }; + request.onerror = function(e) { + browser.test.fail(`Failed with error ${request.error.message}`); + browser.test.notifyFail("done"); + }; + }; + dbRequest.onerror = function(e) { + browser.test.fail(`Failed with error ${dbRequest.error.message}`); + browser.test.notifyFail("done"); + }; + } + }); + }, + }); + + await extension.startup(); + extension.sendMessage("create-storage"); + await extension.awaitMessage("storage-created"); + + await extension.addon.disable(); + + Services.prefs.setBoolPref("privacy.firstparty.isolate", false); + + await extension.addon.enable(); + await extension.awaitStartup(); + + extension.sendMessage("check-storage"); + await extension.awaitFinish("done"); + + await extension.unload(); + Services.prefs.clearUserPref("privacy.firstparty.isolate"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js new file mode 100644 index 0000000000..dd90d9bbc8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js @@ -0,0 +1,150 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_parent_to_child() { + async function background() { + const dbName = "broken-blob"; + const dbStore = "blob-store"; + const dbVersion = 1; + const blobContent = "Hello World!"; + + let db = await new Promise((resolve, reject) => { + let dbOpen = indexedDB.open(dbName, dbVersion); + dbOpen.onerror = event => { + browser.test.fail(`Error opening the DB: ${event.target.error}`); + browser.test.notifyFail("test-completed"); + reject(); + }; + dbOpen.onsuccess = event => { + resolve(event.target.result); + }; + dbOpen.onupgradeneeded = event => { + let dbobj = event.target.result; + dbobj.onerror = error => { + browser.test.fail(`Error updating the DB: ${error.target.error}`); + browser.test.notifyFail("test-completed"); + reject(); + }; + dbobj.createObjectStore(dbStore); + }; + }); + + async function save(blob) { + let txn = db.transaction([dbStore], "readwrite"); + let store = txn.objectStore(dbStore); + let req = store.put(blob, "key"); + + return new Promise((resolve, reject) => { + req.onsuccess = () => { + resolve(); + }; + req.onerror = event => { + browser.test.fail( + `Error saving the blob into the DB: ${event.target.error}` + ); + browser.test.notifyFail("test-completed"); + reject(); + }; + }); + } + + async function load() { + let txn = db.transaction([dbStore], "readonly"); + let store = txn.objectStore(dbStore); + let req = store.getAll(); + + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }) + .then(loadDetails => { + let blobs = []; + loadDetails.forEach(details => { + blobs.push(details); + }); + return blobs[0]; + }) + .catch(err => { + browser.test.fail( + `Error loading the blob from the DB: ${err} :: ${err.stack}` + ); + browser.test.notifyFail("test-completed"); + }); + } + + browser.test.log("Blob creation"); + await save(new Blob([blobContent])); + let blob = await load(); + + db.close(); + + browser.runtime.onMessage.addListener(([msg, what]) => { + browser.test.log("Message received from content: " + msg); + if (msg == "script-ready") { + return Promise.resolve({ blob }); + } + + if (msg == "script-value") { + browser.test.assertEq(blobContent, what, "blob content matches"); + browser.test.notifyPass("test-completed"); + return; + } + + browser.test.fail(`Unexpected test message received: ${msg}`); + }); + + browser.test.sendMessage("bg-ready"); + } + + function contentScriptStart() { + browser.runtime.sendMessage(["script-ready"], response => { + let reader = new FileReader(); + reader.addEventListener( + "load", + () => { + browser.runtime.sendMessage(["script-value", reader.result]); + }, + { once: true } + ); + reader.readAsText(response.blob); + }); + } + + let extensionData = { + background, + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_start.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script_start.js": contentScriptStart, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("bg-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish("test-completed"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js new file mode 100644 index 0000000000..728df04c60 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js @@ -0,0 +1,39 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_json_parser() { + const ID = "json@test.web.extension"; + + let xpi = AddonTestUtils.createTempWebExtensionFile({ + files: { + "manifest.json": String.raw`{ + // This is a manifest. + "applications": {"gecko": {"id": "${ID}"}}, + "name": "This \" is // not a comment", + "version": "0.1\\" // , "description": "This is not a description" + }`, + }, + }); + + let expectedManifest = { + applications: { gecko: { id: ID } }, + name: 'This " is // not a comment', + version: "0.1\\", + }; + + let fileURI = Services.io.newFileURI(xpi); + let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(uri); + + await extension.parseManifest(); + + Assert.deepEqual( + extension.rawManifest, + expectedManifest, + "Manifest with correctly-filtered comments" + ); + + Services.obs.notifyObservers(xpi, "flush-cache-entry"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js new file mode 100644 index 0000000000..b8eb3830fa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js @@ -0,0 +1,150 @@ +"use strict"; + +const { L10nRegistry, FileSource } = ChromeUtils.import( + "resource://gre/modules/L10nRegistry.jsm" +); +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +add_task(async function setup() { + // Add a test .ftl file + // (Note: other tests do this by patching L10nRegistry.load() but in + // this test L10nRegistry is also loaded in the extension process -- + // just adding a new resource is easier than trying to patch + // L10nRegistry in all processes) + let dir = FileUtils.getDir("TmpD", ["l10ntest"]); + dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + await OS.File.writeAtomic( + OS.Path.join(dir.path, "test.ftl"), + "key = value\n" + ); + + let target = Services.io.newFileURI(dir); + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + + resProto.setSubstitution("l10ntest", target); + + const source = new FileSource( + "test", + Services.locale.requestedLocales, + "resource://l10ntest/" + ); + L10nRegistry.registerSources([source]); +}); + +// Test that privileged extensions can use fluent to get strings from +// language packs (and that unprivileged extensions cannot) +add_task(async function test_l10n_dom() { + const PAGE = `<!DOCTYPE html> + <html><head> + <meta charset="utf8"> + <link rel="localization" href="test.ftl"/> + <script src="page.js"></script> + </head></html>`; + + function SCRIPT() { + window.addEventListener( + "load", + async () => { + try { + await document.l10n.ready; + let result = await document.l10n.formatValue("key"); + browser.test.sendMessage("result", { success: true, result }); + } catch (err) { + browser.test.sendMessage("result", { + success: false, + msg: err.message, + }); + } + }, + { once: true } + ); + } + + async function runTest(isPrivileged) { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + manifest: { + web_accessible_resources: ["page.html"], + }, + isPrivileged, + files: { + "page.html": PAGE, + "page.js": SCRIPT, + }, + }); + + await extension.startup(); + let url = await extension.awaitMessage("ready"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + let results = await extension.awaitMessage("result"); + await page.close(); + await extension.unload(); + + return results; + } + + // Everything should work for a privileged extension + let results = await runTest(true); + equal(results.success, true, "Translation succeeded in privileged extension"); + equal(results.result, "value", "Translation got the right value"); + + // In an unprivleged extension, document.l10n shouldn't show up + results = await runTest(false); + equal(results.success, false, "Translation failed in unprivileged extension"); + equal( + results.msg.endsWith("document.l10n is undefined"), + true, + "Translation failed due to missing document.l10n" + ); +}); + +add_task(async function test_l10n_manifest() { + // Fluent can't be used to localize properties that the AddonManager + // reads (see comment inside ExtensionData.parseManifest for details) + // so test by localizing a property that only the extension framework + // cares about: page_action. This means we can only do this test from + // browser. + if (AppConstants.MOZ_BUILD_APP != "browser") { + return; + } + + AddonTestUtils.initializeURLPreloader(); + + async function runTest(isPrivileged) { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged, + manifest: { + l10n_resources: ["test.ftl"], + page_action: { + default_title: "__MSG_key__", + }, + }, + }); + + await extension.startup(); + let title = extension.extension.manifest.page_action.default_title; + await extension.unload(); + return title; + } + + let title = await runTest(true); + equal( + title, + "value", + "Manifest key localized with fluent in privileged extension" + ); + title = await runTest(false); + equal( + title, + "__MSG_key__", + "Manifest key not localized in unprivileged extension" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js new file mode 100644 index 0000000000..9ae4a4a873 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let hasRun = localStorage.getItem("has-run"); + let result; + if (!hasRun) { + localStorage.setItem("has-run", "yup"); + localStorage.setItem("test-item", "item1"); + result = "item1"; + } else { + let data = localStorage.getItem("test-item"); + if (data == "item1") { + localStorage.setItem("test-item", "item2"); + result = "item2"; + } else if (data == "item2") { + localStorage.removeItem("test-item"); + result = "deleted"; + } else if (!data) { + localStorage.clear(); + result = "cleared"; + } + } + browser.test.sendMessage("result", result); + browser.test.notifyPass("localStorage"); +} + +const ID = "test-webextension@mozilla.com"; +let extensionData = { + manifest: { applications: { gecko: { id: ID } } }, + background: backgroundScript, +}; + +add_task(async function test_localStorage() { + const RESULTS = ["item1", "item2", "deleted", "cleared", "item1"]; + + for (let expected of RESULTS) { + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let actual = await extension.awaitMessage("result"); + + await extension.awaitFinish("localStorage"); + await extension.unload(); + + equal(actual, expected, "got expected localStorage data"); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management.js b/toolkit/components/extensions/test/xpcshell/test_ext_management.js new file mode 100644 index 0000000000..c6c73b2249 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management.js @@ -0,0 +1,205 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +add_task(async function setup() { + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_management_permission() { + async function background() { + const permObj = { permissions: ["management"] }; + + let hasPerm = await browser.permissions.contains(permObj); + browser.test.assertTrue(!hasPerm, "does not have management permission"); + browser.test.assertTrue( + !!browser.management, + "management namespace exists" + ); + // These require permission + let requires_permission = [ + "getAll", + "get", + "install", + "setEnabled", + "onDisabled", + "onEnabled", + "onInstalled", + "onUninstalled", + ]; + + async function testAvailable() { + // These are always available regardless of permission. + for (let fn of ["getSelf", "uninstallSelf"]) { + browser.test.assertTrue( + !!browser.management[fn], + `management.${fn} exists` + ); + } + + let hasPerm = await browser.permissions.contains(permObj); + for (let fn of requires_permission) { + browser.test.assertEq( + hasPerm, + !!browser.management[fn], + `management.${fn} does not exist` + ); + } + } + + await testAvailable(); + + browser.test.onMessage.addListener(async msg => { + browser.test.log("test with permission"); + + // get permission + await browser.permissions.request(permObj); + let hasPerm = await browser.permissions.contains(permObj); + browser.test.assertTrue( + hasPerm, + "management permission.request accepted" + ); + await testAvailable(); + + browser.management.onInstalled.addListener(() => { + browser.test.fail("onInstalled listener invoked"); + }); + + browser.test.log("test without permission"); + // remove permission + await browser.permissions.remove(permObj); + hasPerm = await browser.permissions.contains(permObj); + browser.test.assertFalse( + hasPerm, + "management permission.request removed" + ); + await testAvailable(); + + browser.test.sendMessage("done"); + }); + + browser.test.sendMessage("started"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "management@test", + }, + }, + optional_permissions: ["management"], + }, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + await extension.awaitMessage("started"); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + }); + await extension.awaitMessage("done"); + + // Verify the onInstalled listener does not get used. + // The listener will make the test fail if fired. + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "on-installed@test", + }, + }, + optional_permissions: ["management"], + }, + useAddonManager: "temporary", + }); + await ext2.startup(); + await ext2.unload(); + + await extension.unload(); +}); + +add_task(async function test_management_getAll() { + const id1 = "get_all_test1@tests.mozilla.com"; + const id2 = "get_all_test2@tests.mozilla.com"; + + function getManifest(id) { + return { + applications: { + gecko: { + id, + }, + }, + name: id, + version: "1.0", + short_name: id, + permissions: ["management"], + }; + } + + async function background() { + browser.test.onMessage.addListener(async (msg, id) => { + let addon = await browser.management.get(id); + browser.test.sendMessage("addon", addon); + }); + + let addons = await browser.management.getAll(); + browser.test.assertEq( + 2, + addons.length, + "management.getAll returned correct number of add-ons." + ); + browser.test.sendMessage("addons", addons); + } + + let extension1 = ExtensionTestUtils.loadExtension({ + manifest: getManifest(id1), + useAddonManager: "temporary", + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: getManifest(id2), + background, + useAddonManager: "temporary", + }); + + await extension1.startup(); + await extension2.startup(); + + let addons = await extension2.awaitMessage("addons"); + for (let id of [id1, id2]) { + let addon = addons.find(a => { + return a.id === id; + }); + equal( + addon.name, + id, + `The extension with id ${id} was returned by getAll.` + ); + equal(addon.shortName, id, "Additional extension metadata was correct"); + } + + extension2.sendMessage("getAddon", id1); + let addon = await extension2.awaitMessage("addon"); + equal(addon.name, id1, `The extension with id ${id1} was returned by get.`); + equal(addon.shortName, id1, "Additional extension metadata was correct"); + + extension2.sendMessage("getAddon", id2); + addon = await extension2.awaitMessage("addon"); + equal(addon.name, id2, `The extension with id ${id2} was returned by get.`); + equal(addon.shortName, id2, "Additional extension metadata was correct"); + + await extension2.unload(); + await extension1.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js new file mode 100644 index 0000000000..caed4f5525 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js @@ -0,0 +1,146 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { MockRegistrar } = ChromeUtils.import( + "resource://testing-common/MockRegistrar.jsm" +); + +const id = "uninstall_self_test@tests.mozilla.com"; + +const manifest = { + applications: { + gecko: { + id, + }, + }, + name: "test extension name", + version: "1.0", +}; + +const waitForUninstalled = () => + new Promise(resolve => { + const listener = { + onUninstalled: async addon => { + equal(addon.id, id, "The expected add-on has been uninstalled"); + let checkedAddon = await AddonManager.getAddonByID(addon.id); + equal(checkedAddon, null, "Add-on no longer exists"); + AddonManager.removeAddonListener(listener); + resolve(); + }, + }; + AddonManager.addAddonListener(listener); + }); + +let promptService = { + _response: null, + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx: function(...args) { + this._confirmExArgs = args; + return this._response; + }, +}; + +AddonTestUtils.init(this); + +add_task(async function setup() { + let fakePromptService = MockRegistrar.register( + "@mozilla.org/embedcomp/prompt-service;1", + promptService + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakePromptService); + }); + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_management_uninstall_no_prompt() { + function background() { + browser.test.onMessage.addListener(msg => { + browser.management.uninstallSelf(); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + extension.sendMessage("uninstall"); + await waitForUninstalled(); + Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry"); +}); + +add_task(async function test_management_uninstall_prompt_uninstall() { + promptService._response = 0; + + function background() { + browser.test.onMessage.addListener(msg => { + browser.management.uninstallSelf({ showConfirmDialog: true }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + extension.sendMessage("uninstall"); + await waitForUninstalled(); + + // Test localization strings + equal(promptService._confirmExArgs[1], `Uninstall ${manifest.name}`); + equal( + promptService._confirmExArgs[2], + `The extension “${manifest.name}” is requesting to be uninstalled. What would you like to do?` + ); + equal(promptService._confirmExArgs[4], "Uninstall"); + equal(promptService._confirmExArgs[5], "Keep Installed"); + Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry"); +}); + +add_task(async function test_management_uninstall_prompt_keep() { + promptService._response = 1; + + function background() { + browser.test.onMessage.addListener(async msg => { + await browser.test.assertRejects( + browser.management.uninstallSelf({ showConfirmDialog: true }), + "User cancelled uninstall of extension", + "Expected rejection when user declines uninstall" + ); + + browser.test.sendMessage("uninstall-rejected"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + + extension.sendMessage("uninstall"); + await extension.awaitMessage("uninstall-rejected"); + + addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on remains installed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js new file mode 100644 index 0000000000..cf6749f7a8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js @@ -0,0 +1,95 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testIconPaths(icon, manifest, expectedError) { + let normalized = await ExtensionTestUtils.normalizeManifest(manifest); + + if (expectedError) { + ok( + expectedError.test(normalized.error), + `Should have an error for ${JSON.stringify(icon)}` + ); + } else { + ok(!normalized.error, `Should not have an error ${JSON.stringify(icon)}`); + } +} + +add_task(async function test_manifest() { + let badpaths = ["", " ", "\t", "http://foo.com/icon.png"]; + for (let path of badpaths) { + await testIconPaths( + path, + { + icons: path, + }, + /Error processing icons/ + ); + + await testIconPaths( + path, + { + icons: { + "16": path, + }, + }, + /Error processing icons/ + ); + } + + let paths = [ + "icon.png", + "/icon.png", + "./icon.png", + "path to an icon.png", + " icon.png", + ]; + for (let path of paths) { + // manifest.icons is an object + await testIconPaths( + path, + { + icons: path, + }, + /Error processing icons/ + ); + + await testIconPaths(path, { + icons: { + "16": path, + }, + }); + } +}); + +add_task(async function test_manifest_warnings_on_unexpected_props() { + let extension = await ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["bg.js"], + wrong_prop: true, + }, + }, + files: { + "bg.js": "", + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + // Retrieve the warning message collected by the Extension class + // packagingWarning method. + const { warnings } = extension.extension; + equal(warnings.length, 1, "Got the expected number of manifest warnings"); + + const expectedMessage = + "Reading manifest: Warning processing background.wrong_prop"; + ok( + warnings[0].startsWith(expectedMessage), + "Got the expected warning message format" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js new file mode 100644 index 0000000000..92dd5ee821 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js @@ -0,0 +1,82 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +add_task(async function test_manifest_csp() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + content_security_policy: "script-src 'self'; object-src 'none'", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.content_security_policy, + "script-src 'self'; object-src 'none'", + "Should have the expected policy string" + ); + + ExtensionTestUtils.failOnSchemaWarnings(false); + normalized = await ExtensionTestUtils.normalizeManifest({ + content_security_policy: "object-src 'none'", + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + equal(normalized.error, undefined, "Should not have an error"); + + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy: Policy is missing a required ‘script-src’ directive", + ], + "Should have the expected warning" + ); + + equal( + normalized.value.content_security_policy, + null, + "Invalid policy string should be omitted" + ); +}); + +add_task(async function test_manifest_csp_v3() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + content_security_policy: { + extension_pages: "script-src 'self' 'unsafe-eval'; object-src 'none'", + }, + }); + + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy.extension_pages: ‘script-src’ directive contains a forbidden 'unsafe-eval' keyword", + ], + "Should have the expected warning" + ); + equal( + normalized.value.content_security_policy.extension_pages, + null, + "Should have the expected policy string" + ); + + ExtensionTestUtils.failOnSchemaWarnings(false); + normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + content_security_policy: { + extension_pages: "object-src 'none'", + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 1, "Should have warnings"); + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy.extension_pages: Policy is missing a required ‘script-src’ directive", + ], + "Should have the expected warning for extension_pages CSP" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js new file mode 100644 index 0000000000..5aa44c5885 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_incognito() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + let normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "spanning", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.incognito, + "spanning", + "Should have the expected incognito string" + ); + + normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "not_allowed", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.incognito, + "not_allowed", + "Should have the expected incognito string" + ); + + normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "split", + }); + + equal( + normalized.error, + 'Error processing incognito: Invalid enumeration value "split"', + "Should have an error" + ); + Assert.deepEqual(normalized.errors, [], "Should not have a warning"); + equal( + normalized.value, + undefined, + "Invalid incognito string should be undefined" + ); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js new file mode 100644 index 0000000000..39119513fb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js @@ -0,0 +1,12 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_minimum_chrome_version() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + minimum_chrome_version: "42", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js new file mode 100644 index 0000000000..943e8b7270 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js @@ -0,0 +1,12 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_minimum_opera_version() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + minimum_opera_version: "48", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js new file mode 100644 index 0000000000..8cd44f06dc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js @@ -0,0 +1,35 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function test_theme_property(property) { + let normalized = await ExtensionTestUtils.normalizeManifest( + { + theme: { + [property]: {}, + }, + }, + "manifest.ThemeManifest" + ); + + if (property === "unrecognized_key") { + const expectedWarning = `Warning processing theme.${property}`; + ok( + normalized.errors[0].includes(expectedWarning), + `The manifest warning ${JSON.stringify( + normalized.errors[0] + )} must contain ${JSON.stringify(expectedWarning)}` + ); + } else { + equal(normalized.errors.length, 0, "Should have a warning"); + } + equal(normalized.error, undefined, "Should not have an error"); +} + +add_task(async function test_manifest_themes() { + await test_theme_property("images"); + await test_theme_property("colors"); + ExtensionTestUtils.failOnSchemaWarnings(false); + await test_theme_property("unrecognized_key"); + ExtensionTestUtils.failOnSchemaWarnings(true); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js new file mode 100644 index 0000000000..c629c51509 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js @@ -0,0 +1,270 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +const PAGE_HTML = `<!DOCTYPE html><meta charset="utf-8"><script src="script.js"></script>`; + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-page-event", "start-background-page"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +async function test(what, background, script) { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["script.js"], + }, + ], + }, + + files: { + "page.html": PAGE_HTML, + "script.js": script, + }, + + background, + }); + + info(`Set up ${what} listener`); + await extension.startup(); + await extension.awaitMessage("bg-ran"); + + info(`Test wakeup for ${what} from an extension page`); + await promiseRestartManager(); + await extension.awaitStartup(); + + function awaitBgEvent() { + return new Promise(resolve => + extension.extension.once("background-page-event", resolve) + ); + } + + let events = trackEvents(extension); + + let url = extension.extension.baseURI.resolve("page.html"); + + let [, page] = await Promise.all([ + awaitBgEvent(), + ExtensionTestUtils.loadContentPage(url, { extension }), + ]); + + equal( + events.get("background-page-event"), + true, + "Should have gotten a background page event" + ); + equal( + events.get("start-background-page"), + false, + "Background page should not be started" + ); + + equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message"); + + let promise = extension.awaitMessage("bg-ran"); + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await promise; + + equal( + events.get("start-background-page"), + true, + "Should have gotten start-background-page event" + ); + + await extension.awaitFinish("messaging-test"); + ok(true, "Background page loaded and received message from extension page"); + + await page.close(); + + info(`Test wakeup for ${what} from a content script`); + ExtensionParent._resetStartupPromises(); + await promiseRestartManager(); + await extension.awaitStartup(); + + events = trackEvents(extension); + + [, page] = await Promise.all([ + awaitBgEvent(), + ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ), + ]); + + equal( + events.get("background-page-event"), + true, + "Should have gotten a background page event" + ); + equal( + events.get("start-background-page"), + false, + "Background page should not be started" + ); + + equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message"); + + promise = extension.awaitMessage("bg-ran"); + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await promise; + + equal( + events.get("start-background-page"), + true, + "Should have gotten start-background-page event" + ); + + await extension.awaitFinish("messaging-test"); + ok(true, "Background page loaded and received message from content script"); + + await page.close(); + await extension.unload(); + + await promiseShutdownManager(); + ExtensionParent._resetStartupPromises(); +} + +add_task(function test_onMessage() { + function script() { + browser.runtime.sendMessage("ping").then(reply => { + browser.test.assertEq( + reply, + "pong", + "Extension page received pong reply" + ); + browser.test.notifyPass("messaging-test"); + }); + } + + async function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq( + msg, + "ping", + "Background page received ping message" + ); + return Promise.resolve("pong"); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + } + + return test("onMessage", background, script); +}); + +add_task(function test_onConnect() { + function script() { + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "pong", "Extension page received pong reply"); + browser.test.notifyPass("messaging-test"); + }); + port.postMessage("ping"); + } + + async function background() { + browser.runtime.onConnect.addListener(port => { + port.onMessage.addListener(msg => { + browser.test.assertEq( + msg, + "ping", + "Background page received ping message" + ); + port.postMessage("pong"); + }); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + } + + return test("onConnect", background, script); +}); + +// Test that messaging works if the background page is started before +// any messages are exchanged. (See bug 1467136 for an example of how +// this broke at one point). +add_task(async function test_other_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + async background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.notifyPass("startup"); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + }, + + files: { + "page.html": PAGE_HTML, + "script.js"() { + browser.runtime.sendMessage("ping"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ran"); + + await promiseRestartManager(); + await extension.awaitStartup(); + + // Start the background page. No message have been sent at this point. + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + await extension.awaitMessage("bg-ran"); + + // Now that the background page is fully started, load a new page that + // sends a message to the background page. + let url = extension.extension.baseURI.resolve("page.html"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + + await extension.awaitFinish("startup"); + + await page.close(); + await extension.unload(); + + await promiseShutdownManager(); + ExtensionParent._resetStartupPromises(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js new file mode 100644 index 0000000000..f71001a74d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js @@ -0,0 +1,685 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals chrome */ + +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = + "webextensions.native-messaging.max-output-message-bytes"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +const ECHO_BODY = String.raw` + import struct + import sys + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const INFO_BODY = String.raw` + import json + import os + import struct + import sys + + msg = json.dumps({"args": sys.argv, "cwd": os.getcwd()}) + if sys.version_info >= (3,): + sys.stdout.buffer.write(struct.pack('@I', len(msg))) + else: + sys.stdout.write(struct.pack('@I', len(msg))) + sys.stdout.write(msg) + sys.exit(0) +`; + +const STDERR_LINES = ["hello stderr", "this should be a separate line"]; +let STDERR_MSG = STDERR_LINES.join("\\n"); + +const STDERR_BODY = String.raw` + import sys + sys.stderr.write("${STDERR_MSG}") +`; + +let SCRIPTS = [ + { + name: "echo", + description: "a native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "info", + description: "a native app that gives some info about how it was started", + script: INFO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "stderr", + description: "a native app that writes to stderr and then exits", + script: STDERR_BODY.replace(/^ {2}/gm, ""), + }, +]; + +if (AppConstants.platform == "win") { + SCRIPTS.push({ + name: "echocmd", + description: "echo but using a .cmd file", + scriptExtension: "cmd", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }); +} + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +// Test the basic operation of native messaging with a simple +// script that echoes back whatever message is sent to it. +add_task(async function test_happy_path() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("message", msg); + }); + browser.test.onMessage.addListener((what, payload) => { + if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + const tests = [ + { + data: "this is a string", + what: "simple string", + }, + { + data: "Это юникода", + what: "unicode string", + }, + { + data: { test: "hello" }, + what: "simple object", + }, + { + data: { + what: "An object with a few properties", + number: 123, + bool: true, + nested: { what: "another object" }, + }, + what: "object with several properties", + }, + + { + data: { + ignoreme: true, + _json: { data: "i have a tojson method" }, + }, + expected: { data: "i have a tojson method" }, + what: "object with toJSON() method", + }, + ]; + for (let test of tests) { + extension.sendMessage("send", test.data); + let response = await extension.awaitMessage("message"); + let expected = test.expected || test.data; + deepEqual(response, expected, `Echoed a message of type ${test.what}`); + } + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +// Just test that the given app (which should be the echo script above) +// can be started. Used to test corner cases in how the native application +// is located/launched. +async function simpleTest(app) { + function background(appname) { + let port = browser.runtime.connectNative(appname); + let MSG = "test"; + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + browser.test.sendMessage("done"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${JSON.stringify(app)});`, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +} + +if (AppConstants.platform == "win") { + // "relative.echo" has a relative path in the host manifest. + add_task(function test_relative_path() { + return simpleTest("relative.echo"); + }); + + // "echocmd" uses a .cmd file instead of a .bat file + add_task(function test_cmd_file() { + return simpleTest("echocmd"); + }); +} + +// Test sendNativeMessage() +add_task(async function test_sendNativeMessage() { + async function background() { + let MSG = { test: "hello world" }; + + // Check error handling + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("nonexistent", MSG), + /Attempt to postMessage on disconnected port/, + "sendNativeMessage() to a nonexistent app failed" + ); + + // Check regular message exchange + let reply = await browser.runtime.sendNativeMessage("echo", MSG); + + let expected = JSON.stringify(MSG); + let received = JSON.stringify(reply); + browser.test.assertEq(expected, received, "Received echoed native message"); + + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + + // With sendNativeMessage(), the subprocess should be disconnected + // after exchanging a single message. + await waitForSubprocessExit(); + + await extension.unload(); +}); + +// Test calling Port.disconnect() +add_task(async function test_disconnect() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onMessage.addListener((msg, msgPort) => { + browser.test.assertEq( + port, + msgPort, + "onMessage handler should receive the port as the second argument" + ); + browser.test.sendMessage("message", msg); + }); + port.onDisconnect.addListener(msgPort => { + browser.test.fail("onDisconnect should not be called for disconnect()"); + }); + browser.test.onMessage.addListener((what, payload) => { + if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } else if (what == "disconnect") { + try { + port.disconnect(); + browser.test.assertThrows( + () => port.postMessage("void"), + "Attempt to postMessage on disconnected port" + ); + browser.test.sendMessage("disconnect-result", { success: true }); + } catch (err) { + browser.test.sendMessage("disconnect-result", { + success: false, + errmsg: err.message, + }); + } + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("send", "test"); + let response = await extension.awaitMessage("message"); + equal(response, "test", "Echoed a string"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "disconnect succeeded"); + + info("waiting for subprocess to exit"); + await waitForSubprocessExit(); + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess is no longer running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "second call to disconnect silently ignored"); + + await extension.unload(); +}); + +// Test the limit on message size for writing +add_task(async function test_write_limit() { + Services.prefs.setIntPref(PREF_MAX_WRITE, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_WRITE); + } + registerCleanupFunction(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + try { + port.postMessage(PAYLOAD); + browser.test.sendMessage("result", null); + } catch (ex) { + browser.test.sendMessage("result", ex.message); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let errmsg = await extension.awaitMessage("result"); + notEqual( + errmsg, + null, + "native postMessage() failed for overly large message" + ); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test the limit on message size for reading +add_task(async function test_read_limit() { + Services.prefs.setIntPref(PREF_MAX_READ, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_READ); + } + registerCleanupFunction(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "Native application tried to send a message of 13 bytes, which exceeds the limit of 10 bytes.", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage(PAYLOAD); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let result = await extension.awaitMessage("result"); + equal( + result, + "disconnected", + "native port disconnected on receiving large message" + ); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test that an extension without the nativeMessaging permission cannot +// use native messaging. +add_task(async function test_ext_permission() { + function background() { + browser.test.assertEq( + chrome.runtime.connectNative, + undefined, + "chrome.runtime.connectNative does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + browser.runtime.connectNative, + undefined, + "browser.runtime.connectNative does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + chrome.runtime.sendNativeMessage, + undefined, + "chrome.runtime.sendNativeMessage does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + browser.runtime.sendNativeMessage, + undefined, + "browser.runtime.sendNativeMessage does not exist without nativeMessaging permission" + ); + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: {}, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); +}); + +// Test that an extension that is not listed in allowed_extensions for +// a native application cannot use that application. +add_task(async function test_app_permission() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "No such native application echo", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage({ test: "test" }); + } + + let extension = ExtensionTestUtils.loadExtension( + { + background, + manifest: { + permissions: ["nativeMessaging"], + }, + }, + "somethingelse@tests.mozilla.org" + ); + + await extension.startup(); + + let result = await extension.awaitMessage("result"); + equal( + result, + "disconnected", + "connectNative() failed without native app permission" + ); + + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); +}); + +// Test that the command-line arguments and working directory for the +// native application are as expected. +add_task(async function test_child_process() { + function background() { + let port = browser.runtime.connectNative("info"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", msg); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let msg = await extension.awaitMessage("result"); + equal(msg.args.length, 3, "Received two command line arguments"); + equal( + msg.args[1], + getPath("info.json"), + "Command line argument is the path to the native host manifest" + ); + equal( + msg.args[2], + ID, + "Second command line argument is the ID of the calling extension" + ); + equal( + msg.cwd.replace(/^\/private\//, "/"), + OS.Path.join(tmpDir.path, TYPE_SLUG), + "Working directory is the directory containing the native appliation" + ); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +add_task(async function test_stderr() { + function background() { + let port = browser.runtime.connectNative("stderr"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + null, + port.error, + "Normal application exit is not an error" + ); + browser.test.sendMessage("finished"); + }); + } + + let { messages } = await promiseConsoleOutput(async function() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); + + await waitForSubprocessExit(); + }); + + let lines = STDERR_LINES.map(line => + messages.findIndex(msg => msg.message.includes(line)) + ); + notEqual(lines[0], -1, "Saw first line of stderr output on the console"); + notEqual(lines[1], -1, "Saw second line of stderr output on the console"); + notEqual( + lines[0], + lines[1], + "Stderr output lines are separated in the console" + ); +}); + +// Test that calling connectNative() multiple times works +// (see bug 1313980 for a previous regression in this area) +add_task(async function test_multiple_connects() { + async function background() { + function once() { + return new Promise(resolve => { + let MSG = "hello"; + let port = browser.runtime.connectNative("echo"); + + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + port.disconnect(); + resolve(); + }); + port.postMessage(MSG); + }); + } + + await once(); + await once(); + browser.test.notifyPass("multiple-connect"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("multiple-connect"); + await extension.unload(); +}); + +// Test that native messaging is always rejected on content scripts +add_task(async function test_connect_native_from_content_script() { + async function testScript() { + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "An unexpected error occurred", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage({ test: "test" }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/dummy"], + }, + ], + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + files: { + "test.js": testScript, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + let result = await extension.awaitMessage("result"); + equal(result, "disconnected", "connectNative() failed from content script"); + + await page.close(); + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js new file mode 100644 index 0000000000..073c83bfd4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js @@ -0,0 +1,130 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const MAX_ROUND_TRIP_TIME_MS = + AppConstants.DEBUG || AppConstants.ASAN ? 60 : 30; +const MAX_RETRIES = 5; + +const ECHO_BODY = String.raw` + import struct + import sys + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const SCRIPTS = [ + { + name: "echo", + description: "A native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +add_task(async function test_round_trip_perf() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(msg => { + if (msg != "run-tests") { + return; + } + + let port = browser.runtime.connectNative("echo"); + + function next() { + port.postMessage({ + Lorem: { + ipsum: { + dolor: [ + "sit amet", + "consectetur adipiscing elit", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ], + "Ut enim": [ + "ad minim veniam", + "quis nostrud exercitation ullamco", + "laboris nisi ut aliquip ex ea commodo consequat.", + ], + Duis: [ + "aute irure dolor in reprehenderit in", + "voluptate velit esse cillum dolore eu", + "fugiat nulla pariatur.", + ], + Excepteur: [ + "sint occaecat cupidatat non proident", + "sunt in culpa qui officia deserunt", + "mollit anim id est laborum.", + ], + }, + }, + }); + } + + const COUNT = 1000; + let now; + function finish() { + let roundTripTime = (Date.now() - now) / COUNT; + + port.disconnect(); + browser.test.sendMessage("result", roundTripTime); + } + + let count = 0; + port.onMessage.addListener(() => { + if (count == 0) { + // Skip the first round, since it includes the time it takes + // the app to start up. + now = Date.now(); + } + + if (count++ <= COUNT) { + next(); + } else { + finish(); + } + }); + + next(); + }); + }, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let roundTripTime = Infinity; + for ( + let i = 0; + i < MAX_RETRIES && roundTripTime > MAX_ROUND_TRIP_TIME_MS; + i++ + ) { + extension.sendMessage("run-tests"); + roundTripTime = await extension.awaitMessage("result"); + } + + await extension.unload(); + + ok( + roundTripTime <= MAX_ROUND_TRIP_TIME_MS, + `Expected round trip time (${roundTripTime}ms) to be less than ${MAX_ROUND_TRIP_TIME_MS}ms` + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js new file mode 100644 index 0000000000..0de24c0c6e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js @@ -0,0 +1,85 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const WONTDIE_BODY = String.raw` + import signal + import struct + import sys + import time + + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + def spin(): + while True: + try: + signal.pause() + except AttributeError: + time.sleep(5) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + spin() + + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const SCRIPTS = [ + { + name: "wontdie", + description: + "a native app that does not exit when stdin closes or on SIGTERM", + script: WONTDIE_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +// Test that an unresponsive native application still gets killed eventually +add_task(async function test_unresponsive_native_app() { + // XXX expose GRACEFUL_SHUTDOWN_TIME as a pref and reduce it + // just for this test? + + function background() { + let port = browser.runtime.connectNative("wontdie"); + + const MSG = "echo me"; + // bounce a message to make sure the process actually starts + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, MSG, "Received echoed message"); + browser.test.sendMessage("ready"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; + + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess was successfully killed"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js new file mode 100644 index 0000000000..758bf48d0b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js @@ -0,0 +1,190 @@ +"use strict"; + +const Cm = Components.manager; + +const uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService( + Ci.nsIUUIDGenerator +); + +var mockNetworkStatusService = { + contractId: "@mozilla.org/network/network-link-service;1", + + _mockClassId: uuidGenerator.generateUUID(), + + _originalClassId: "", + + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + + createInstance(outer, iiD) { + if (outer) { + throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION); + } + return this.QueryInterface(iiD); + }, + + register() { + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + if (!registrar.isCIDRegistered(this._mockClassId)) { + this._originalClassId = registrar.contractIDToCID(this.contractId); + registrar.registerFactory( + this._mockClassId, + "Unregister after testing", + this.contractId, + this + ); + } + }, + + unregister() { + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(this._mockClassId, this); + registrar.registerFactory(this._originalClassId, "", this.contractId, null); + }, + + _isLinkUp: true, + _linkStatusKnown: false, + _linkType: Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN, + + get isLinkUp() { + return this._isLinkUp; + }, + + get linkStatusKnown() { + return this._linkStatusKnown; + }, + + setLinkStatus(status) { + switch (status) { + case "up": + this._isLinkUp = true; + this._linkStatusKnown = true; + this._networkID = "foo"; + break; + case "down": + this._isLinkUp = false; + this._linkStatusKnown = true; + this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN; + this._networkID = undefined; + break; + case "changed": + this._linkStatusKnown = true; + this._networkID = "foo"; + break; + case "unknown": + this._linkStatusKnown = false; + this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN; + this._networkID = undefined; + break; + } + Services.obs.notifyObservers(null, "network:link-status-changed", status); + }, + + get linkType() { + return this._linkType; + }, + + setLinkType(val) { + this._linkType = val; + this._linkStatusKnown = true; + this._isLinkUp = true; + this._networkID = "bar"; + Services.obs.notifyObservers( + null, + "network:link-type-changed", + this._linkType + ); + }, + + get networkID() { + return this._networkID; + }, +}; + +// nsINetworkLinkService is not directly testable. With the mock service above, +// we just exercise a couple small things here to validate the api works somewhat. +add_task(async function test_networkStatus() { + mockNetworkStatusService.register(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "networkstatus@tests.mozilla.org" } }, + permissions: ["networkStatus"], + }, + isPrivileged: true, + async background() { + browser.networkStatus.onConnectionChanged.addListener(async details => { + browser.test.log(`connection status ${JSON.stringify(details)}`); + browser.test.sendMessage("connect-changed", { + details, + linkInfo: await browser.networkStatus.getLinkInfo(), + }); + }); + browser.test.sendMessage( + "linkdata", + await browser.networkStatus.getLinkInfo() + ); + }, + }); + + async function test(expected, change) { + if (change.status) { + info(`test link change status to ${change.status}`); + mockNetworkStatusService.setLinkStatus(change.status); + } else if (change.link) { + info(`test link change type to ${change.link}`); + mockNetworkStatusService.setLinkType(change.link); + } + let { details, linkInfo } = await extension.awaitMessage("connect-changed"); + equal(details.type, expected.type, "network type is correct"); + equal(details.status, expected.status, `network status is correct`); + equal(details.id, expected.id, "network id"); + Assert.deepEqual( + linkInfo, + details, + "getLinkInfo should resolve to the same details received from onConnectionChanged" + ); + } + + await extension.startup(); + + let data = await extension.awaitMessage("linkdata"); + equal(data.type, "unknown", "network type is unknown"); + equal(data.status, "unknown", `network status is ${data.status}`); + equal(data.id, undefined, "network id"); + + await test( + { type: "unknown", status: "up", id: "foo" }, + { status: "changed" } + ); + + await test( + { type: "wifi", status: "up", id: "bar" }, + { link: Ci.nsINetworkLinkService.LINK_TYPE_WIFI } + ); + + await test({ type: "unknown", status: "down" }, { status: "down" }); + + await test({ type: "unknown", status: "unknown" }, { status: "unknown" }); + + await extension.unload(); + mockNetworkStatusService.unregister(); +}); + +add_task(async function test_networkStatus_permission() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { id: "networkstatus-permission@tests.mozilla.org" }, + }, + permissions: ["networkStatus"], + }, + async background() { + browser.test.assertEq( + undefined, + browser.networkStatus, + "networkStatus is privileged" + ); + }, + }); + await extension.startup(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js new file mode 100644 index 0000000000..400d60c4a1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js @@ -0,0 +1,108 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + +const createdAlerts = []; + +const mockAlertsService = { + showPersistentNotification(persistentData, alert, alertListener) { + this.showAlert(alert, alertListener); + }, + + showAlert(alert, listener) { + createdAlerts.push(alert); + listener.observe(null, "alertfinished", alert.cookie); + }, + + showAlertNotification( + imageUrl, + title, + text, + textClickable, + cookie, + alertListener, + name, + dir, + lang, + data, + principal, + privateBrowsing + ) { + this.showAlert({ cookie, title, text, privateBrowsing }, alertListener); + }, + + closeAlert(name) { + // This mock immediately close the alert on show, so this is empty. + }, + + QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]), + + createInstance(outer, iid) { + if (outer != null) { + throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION); + } + return this.QueryInterface(iid); + }, +}; + +const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +registrar.registerFactory( + Components.ID("{173a036a-d678-4415-9cff-0baff6bfe554}"), + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService +); + +add_task(async function test_notification_privateBrowsing_flag() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["notifications"], + }, + files: { + "page.html": `<meta charset="utf-8"><script src="page.js"></script>`, + async "page.js"() { + let closedPromise = new Promise(resolve => { + browser.notifications.onClosed.addListener(resolve); + }); + let createdId = await browser.notifications.create("notifid", { + type: "basic", + title: "titl", + message: "msg", + }); + let closedId = await closedPromise; + browser.test.assertEq(createdId, closedId, "ID of closed notification"); + browser.test.assertEq( + "{}", + JSON.stringify(await browser.notifications.getAll()), + "no notifications left" + ); + browser.test.sendMessage("notification_closed"); + }, + }, + }); + await extension.startup(); + + async function checkPrivateBrowsingFlag(privateBrowsing) { + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html`, + { extension, remote: extension.extension.remote, privateBrowsing } + ); + await extension.awaitMessage("notification_closed"); + await contentPage.close(); + + Assert.equal(createdAlerts.length, 1, "expected one alert"); + let notification = createdAlerts.shift(); + Assert.equal(notification.cookie, "notifid", "notification id"); + Assert.equal(notification.title, "titl", "notification title"); + Assert.equal(notification.text, "msg", "notification text"); + Assert.equal(notification.privateBrowsing, privateBrowsing, "pbm flag"); + } + + await checkPrivateBrowsingFlag(false); + await checkPrivateBrowsingFlag(true); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js new file mode 100644 index 0000000000..1213ae4f23 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js @@ -0,0 +1,41 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; +const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +registrar.registerFactory( + Components.ID("{18f25bb4-ab12-4e24-b3b0-69215056160b}"), + "unsupported alerts service", + ALERTS_SERVICE_CONTRACT_ID, + {} // This object lacks an implementation of nsIAlertsService. +); + +add_task(async function test_notification_unsupported_backend() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + async background() { + let closedPromise = new Promise(resolve => { + browser.notifications.onClosed.addListener(resolve); + }); + let createdId = await browser.notifications.create("notifid", { + type: "basic", + title: "titl", + message: "msg", + }); + let closedId = await closedPromise; + browser.test.assertEq(createdId, closedId, "ID of closed notification"); + browser.test.assertEq( + "{}", + JSON.stringify(await browser.notifications.getAll()), + "no notifications left" + ); + browser.test.sendMessage("notification_closed"); + }, + }); + await extension.startup(); + await extension.awaitMessage("notification_closed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js new file mode 100644 index 0000000000..7da12b40aa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js @@ -0,0 +1,30 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + function listener() { + browser.test.notifyFail("listener should not be invoked"); + } + + browser.runtime.onMessage.addListener(listener); + browser.runtime.onMessage.removeListener(listener); + browser.runtime.sendMessage("hello"); + + // Make sure that, if we somehow fail to remove the listener, then we'll run + // the listener before the test is marked as passing. + setTimeout(function() { + browser.test.notifyPass("onmessage_removelistener"); + }, 0); +} + +let extensionData = { + background: backgroundScript, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("onmessage_removelistener"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js new file mode 100644 index 0000000000..7e1370d00f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +const ENABLE_COUNTER_PREF = + "extensions.webextensions.enablePerformanceCounters"; +const TIMING_MAX_AGE = "extensions.webextensions.performanceCountersMaxAge"; + +let { ParentAPIManager } = ExtensionParent; + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); // eslint-disable-line mozilla/no-arbitrary-setTimeout +} + +async function retrieveSpecificCounter(apiName, expectedCount) { + let currentCount = 0; + let data; + while (currentCount < expectedCount) { + data = await ParentAPIManager.retrievePerformanceCounters(); + for (let [console, counters] of data) { + for (let [api, counter] of counters) { + if (api == apiName) { + currentCount += counter.calls; + } + } + } + await sleep(100); + } + return data; +} + +async function test_counter() { + async function background() { + // creating a bookmark is done in the parent + let folder = await browser.bookmarks.create({ title: "Folder" }); + await browser.bookmarks.create({ + title: "Bookmark", + url: "http://example.com", + parentId: folder.id, + }); + + // getURL() is done in the child, let do three + browser.extension.getURL("beasts/frog.html"); + browser.extension.getURL("beasts/frog2.html"); + browser.extension.getURL("beasts/frog3.html"); + browser.test.sendMessage("done"); + } + + let extensionData = { + background, + manifest: { + permissions: ["bookmarks"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("done"); + + let counters = await retrieveSpecificCounter("getURL", 3); + await extension.unload(); + + // check that the bookmarks.create API was tracked + let counter = counters.get(extension.id).get("bookmarks.create"); + ok(counter.calls > 0); + ok(counter.duration > 0); + + // check that the getURL API was tracked + counter = counters.get(extension.id).get("getURL"); + ok(counter.calls > 0); + ok(counter.duration > 0); +} + +add_task(function test_performance_counter() { + return runWithPrefs( + [ + [ENABLE_COUNTER_PREF, true], + [TIMING_MAX_AGE, 1], + ], + test_counter + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js new file mode 100644 index 0000000000..cc15e28200 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js @@ -0,0 +1,654 @@ +"use strict"; + +let { ExtensionTestCommon } = ChromeUtils.import( + "resource://testing-common/ExtensionTestCommon.jsm" +); + +let bundle; +if (AppConstants.MOZ_APP_NAME == "thunderbird") { + bundle = Services.strings.createBundle( + "chrome://messenger/locale/addons.properties" + ); +} else { + bundle = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); +} +const DUMMY_APP_NAME = "Dummy brandName"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +async function getManifestPermissions(extensionData) { + let extension = ExtensionTestCommon.generate(extensionData); + // Some tests contain invalid permissions; ignore the warnings about their invalidity. + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.loadManifest(); + ExtensionTestUtils.failOnSchemaWarnings(true); + const { manifestPermissions } = extension; + await extension.cleanupGeneratedFile(); + return manifestPermissions; +} + +function getPermissionWarnings(manifestPermissions, options) { + let info = { + permissions: manifestPermissions, + appName: DUMMY_APP_NAME, + }; + let { msgs } = ExtensionData.formatPermissionStrings(info, bundle, options); + return msgs; +} + +async function getPermissionWarningsForUpdate( + oldExtensionData, + newExtensionData +) { + let oldPerms = await getManifestPermissions(oldExtensionData); + let newPerms = await getManifestPermissions(newExtensionData); + let difference = Extension.comparePermissions(oldPerms, newPerms); + return getPermissionWarnings(difference); +} + +// Tests that the expected permission warnings are generated for various +// combinations of host permissions. +add_task(async function host_permissions() { + let { PluralForm } = ChromeUtils.import( + "resource://gre/modules/PluralForm.jsm" + ); + + let permissionTestCases = [ + { + description: "Empty manifest without permissions", + manifest: {}, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "Invalid match patterns", + manifest: { + permissions: [ + "https:///", + "https://", + "https://*", + "about:ugh", + "about:*", + "about://*/", + "resource://*/", + ], + }, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "moz-extension: permissions", + manifest: { + permissions: ["moz-extension://*/*", "moz-extension://uuid/"], + }, + // moz-extension:-origin does not appear in the permission list, + // but it is implicitly granted anyway. + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "*. host permission", + manifest: { + // This permission is rejected by the manifest and ignored. + permissions: ["http://*./"], + }, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "<all_urls> permission", + manifest: { + permissions: ["<all_urls>"], + }, + expectedOrigins: ["<all_urls>"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "file: permissions", + manifest: { + permissions: ["file://*/"], + }, + expectedOrigins: ["file://*/"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "http: permission", + manifest: { + permissions: ["http://*/"], + }, + expectedOrigins: ["http://*/"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "*://*/ permission", + manifest: { + permissions: ["*://*/"], + }, + expectedOrigins: ["*://*/"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "content_script[*].matches", + manifest: { + content_scripts: [ + { + // This test uses the manifest file without loading the content script + // file, so we can use a non-existing dummy file. + js: ["dummy.js"], + matches: ["https://*/"], + }, + ], + }, + expectedOrigins: ["https://*/"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "A few host permissions", + manifest: { + permissions: ["http://a/", "http://*.b/", "http://c/*"], + }, + expectedOrigins: ["http://a/", "http://*.b/", "http://c/*"], + expectedWarnings: [ + // Wildcard hosts take precedence in the permission list. + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "b", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "a", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "c", + ]), + ], + }, + { + description: "many host permission", + manifest: { + permissions: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + ], + }, + expectedOrigins: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + ], + expectedWarnings: [ + // Wildcard hosts take precedence in the permission list. + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "1", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "2", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "3", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "4", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "a", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "b", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "c", + ]), + PluralForm.get( + 2, + bundle.GetStringFromName("webextPerms.hostDescription.tooManySites") + ).replace("#1", "2"), + ], + options: { + collapseOrigins: true, + }, + }, + { + description: + "many host permissions without item limit in the warning list", + manifest: { + permissions: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + "http://*.5/", + ], + }, + expectedOrigins: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + "http://*.5/", + ], + expectedWarnings: [ + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "1", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "2", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "3", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "4", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "5", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "a", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "b", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "c", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "d", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "e", + ]), + ], + }, + ]; + for (let { + description, + manifest, + expectedOrigins, + expectedWarnings, + options, + } of permissionTestCases) { + let manifestPermissions = await getManifestPermissions({ + manifest, + }); + + deepEqual( + manifestPermissions.origins, + expectedOrigins, + `Expected origins (${description})` + ); + deepEqual( + manifestPermissions.permissions, + [], + `Expected no non-host permissions (${description})` + ); + + let warnings = getPermissionWarnings(manifestPermissions, options); + deepEqual(warnings, expectedWarnings, `Expected warnings (${description})`); + } +}); + +// Tests that the expected permission warnings are generated for a mix of host +// permissions and API permissions. +add_task(async function api_permissions() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + permissions: [ + "activeTab", + "webNavigation", + "tabs", + "nativeMessaging", + "http://x/", + "http://*.x/", + "http://*.tld/", + ], + }, + }); + deepEqual( + manifestPermissions, + { + origins: ["http://x/", "http://*.x/", "http://*.tld/"], + permissions: ["activeTab", "webNavigation", "tabs", "nativeMessaging"], + }, + "Expected origins and permissions" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [ + // Host permissions first, with wildcards on top. + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "x", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "tld", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["x"]), + // nativeMessaging permission warning first of all permissions. + bundle.formatStringFromName("webextPerms.description.nativeMessaging", [ + DUMMY_APP_NAME, + ]), + // Other permissions in alphabetical order. + // Note: activeTab has no permission warning string. + bundle.GetStringFromName("webextPerms.description.tabs"), + bundle.GetStringFromName("webextPerms.description.webNavigation"), + ], + "Expected warnings" + ); +}); + +// Tests that the expected permission warnings are generated for a mix of host +// permissions and API permissions, for a privileged extension that uses the +// mozillaAddons permission. +add_task(async function privileged_with_mozillaAddons() { + let manifestPermissions = await getManifestPermissions({ + isPrivileged: true, + manifest: { + permissions: [ + "mozillaAddons", + "mozillaAddons", + "mozillaAddons", + "resource://x/*", + "http://a/", + "about:reader*", + ], + }, + }); + deepEqual( + manifestPermissions, + { + origins: ["resource://x/*", "http://a/", "about:reader*"], + permissions: ["mozillaAddons"], + }, + "Expected origins and permissions for privileged add-on with mozillaAddons" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [bundle.GetStringFromName("webextPerms.hostDescription.allUrls")], + "Expected warnings for privileged add-on with mozillaAddons permission." + ); +}); + +// Similar to the privileged_with_mozillaAddons test, except the test extension +// is unprivileged and not allowed to use the mozillaAddons permission. +add_task(async function unprivileged_with_mozillaAddons() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + permissions: [ + "mozillaAddons", + "mozillaAddons", + "mozillaAddons", + "resource://x/*", + "http://a/", + "about:reader*", + ], + }, + }); + deepEqual( + manifestPermissions, + { + origins: ["http://a/"], + permissions: [], + }, + "Expected origins and permissions for unprivileged add-on with mozillaAddons" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["a"])], + "Expected warnings for unprivileged add-on with mozillaAddons permission." + ); +}); + +// Tests that an update with less permissions has no warning. +add_task(async function update_drop_permission() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["<all_urls>", "https://a/", "http://b/"], + }, + }, + { + manifest: { + permissions: [ + "https://a/", + "http://b/", + "ftp://host_matching_all_urls/", + ], + }, + } + ); + deepEqual( + warnings, + [], + "An update with fewer permissions should not have any warnings" + ); +}); + +// Tests that an update that switches from "*://*/*" to "<all_urls>" does not +// result in additional permission warnings. +add_task(async function update_all_urls_permission() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["*://*/*"], + }, + }, + { + manifest: { + permissions: ["<all_urls>"], + }, + } + ); + deepEqual( + warnings, + [], + "An update from a wildcard host to <all_urls> should not have any warnings" + ); +}); + +// Tests that an update where a new permission whose domain overlaps with +// an existing permission does not result in additional permission warnings. +add_task(async function update_change_permissions() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["https://a/", "http://*.b/", "http://c/", "http://f/"], + }, + }, + { + manifest: { + permissions: [ + // (no new warning) Unchanged permission from old extension. + "https://a/", + // (no new warning) Different schemes, host should match "*.b" wildcard. + "ftp://ftp.b/", + "ws://ws.b/", + "wss://wss.b", + "https://https.b/", + "http://http.b/", + "*://*.b/", + "http://b/", + + // (expect warning) Wildcard was added. + "http://*.c/", + // (no new warning) file:-scheme, but host "f" is same as "http://f/". + "file://f/", + // (expect warning) New permission was added. + "proxy", + ], + }, + } + ); + deepEqual( + warnings, + [ + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "c", + ]), + bundle.formatStringFromName("webextPerms.description.proxy", [ + DUMMY_APP_NAME, + ]), + ], + "Expected permission warnings for new permissions only" + ); +}); + +// Tests that a privileged extension with the mozillaAddons permission can be +// updated without errors. +add_task(async function update_privileged_with_mozillaAddons() { + let warnings = await getPermissionWarningsForUpdate( + { + isPrivileged: true, + manifest: { + permissions: ["mozillaAddons", "resource://a/"], + }, + }, + { + isPrivileged: true, + manifest: { + permissions: ["mozillaAddons", "resource://a/", "resource://b/"], + }, + } + ); + deepEqual( + warnings, + [bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["b"])], + "Expected permission warnings for new host only" + ); +}); + +// Tests that an unprivileged extension cannot get privileged permissions +// through an update. +add_task(async function update_unprivileged_with_mozillaAddons() { + // Unprivileged + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["mozillaAddons", "resource://a/"], + }, + }, + { + manifest: { + permissions: ["mozillaAddons", "resource://a/", "resource://b/"], + }, + } + ); + deepEqual( + warnings, + [], + "resource:-scheme is unsupported for unprivileged extensions" + ); +}); + +// Tests that invalid permission warning for privileged permissions requested +// without the privilged signature are emitted by the Extension class instance +// but not for the ExtensionData instances (on which the signature is not +// available and the warning would be emitted even for the ones signed correctly). +add_task( + async function test_invalid_permission_warning_on_privileged_permission() { + await AddonTestUtils.promiseStartupManager(); + + async function testInvalidPermissionWarning({ isPrivileged }) { + let id = isPrivileged + ? "privileged-addon@mochi.test" + : "nonprivileged-addon@mochi.test"; + + let expectedWarnings = isPrivileged + ? [] + : ["Reading manifest: Invalid extension permission: mozillaAddons"]; + + const ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["mozillaAddons"], + applications: { gecko: { id } }, + }, + background() {}, + }); + + await ext.startup(); + const { warnings } = ext.extension; + Assert.deepEqual( + warnings, + expectedWarnings, + `Got the expected warning for ${id}` + ); + await ext.unload(); + } + + await testInvalidPermissionWarning({ isPrivileged: false }); + await testInvalidPermissionWarning({ isPrivileged: true }); + + info("Test invalid permission warning on ExtensionData instance"); + // Generate an extension (just to be able to reuse its rootURI for the + // ExtensionData instance created below). + let generatedExt = ExtensionTestCommon.generate({ + manifest: { + permissions: ["mozillaAddons"], + applications: { gecko: { id: "extension-data@mochi.test" } }, + }, + }); + + // Verify that XPIInstall.jsm will not collect the warning for the + // privileged permission as expected. + const extData = new ExtensionData(generatedExt.rootURI); + await extData.loadManifest(); + Assert.deepEqual( + extData.warnings, + [], + "No warnings for mozillaAddons permission collected for the ExtensionData instance" + ); + + // This assertion is just meant to prevent the test to pass if there were no warnings + // because some errors prevented the warnings to be collected). + Assert.deepEqual( + extData.errors, + [], + "No errors collected by the ExtensionData instance" + ); + // Cleanup the generated xpi file. + await generatedExt.cleanupGeneratedFile(); + + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js new file mode 100644 index 0000000000..4b9ade044c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js @@ -0,0 +1,235 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["xpcshell.test", "example.com", "example.org"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/example.txt", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_simple() { + async function runTests(cx) { + function xhr(XMLHttpRequest) { + return url => { + return new Promise((resolve, reject) => { + let req = new XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", resolve); + req.addEventListener("error", reject); + req.send(); + }); + }; + } + + function run(shouldFail, fetch) { + function passListener() { + browser.test.succeed(`${cx}.${fetch.name} pass listener`); + } + + function failListener() { + browser.test.fail(`${cx}.${fetch.name} fail listener`); + } + + /* eslint-disable no-else-return */ + if (shouldFail) { + return fetch("http://example.org/example.txt").then( + failListener, + passListener + ); + } else { + return fetch("http://example.com/example.txt").then( + passListener, + failListener + ); + } + /* eslint-enable no-else-return */ + } + + try { + await run(true, xhr(XMLHttpRequest)); + await run(false, xhr(XMLHttpRequest)); + await run(true, xhr(window.XMLHttpRequest)); + await run(false, xhr(window.XMLHttpRequest)); + await run(true, fetch); + await run(false, fetch); + await run(true, window.fetch); + await run(false, window.fetch); + } catch (err) { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("permission_xhr"); + } + } + + async function background(runTestsFn) { + await runTestsFn("bg"); + browser.test.notifyPass("permission_xhr"); + } + + let extensionData = { + background: `(${background})(${runTests})`, + manifest: { + permissions: ["http://example.com/"], + content_scripts: [ + { + matches: ["http://xpcshell.test/data/file_permission_xhr.html"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": `(${async runTestsFn => { + await runTestsFn("content"); + + window.wrappedJSObject.privilegedFetch = fetch; + window.wrappedJSObject.privilegedXHR = XMLHttpRequest; + + window.addEventListener("message", function rcv({ data }) { + switch (data.msg) { + case "test": + break; + + case "assertTrue": + browser.test.assertTrue(data.condition, data.description); + break; + + case "finish": + window.removeEventListener("message", rcv); + browser.test.sendMessage("content-script-finished"); + break; + } + }); + window.postMessage("test", "*"); + }})(${runTests})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://xpcshell.test/data/file_permission_xhr.html" + ); + await extension.awaitMessage("content-script-finished"); + await contentPage.close(); + + await extension.awaitFinish("permission_xhr"); + await extension.unload(); +}); + +// This test case ensures that a WebExtension content script can still use the same +// XMLHttpRequest and fetch APIs that the webpage can use and be recognized from +// the target server with the same origin and referer headers of the target webpage +// (see Bug 1295660 for a rationale). +add_task(async function test_page_xhr() { + async function contentScript() { + const content = this.content; + + const { webpageFetchResult, webpageXhrResult } = await new Promise( + resolve => { + const listenPageMessage = event => { + if (!event.data || event.data.type !== "testPageGlobals") { + return; + } + + window.removeEventListener("message", listenPageMessage); + + browser.test.assertEq( + true, + !!content.XMLHttpRequest, + "The content script should have access to content.XMLHTTPRequest" + ); + browser.test.assertEq( + true, + !!content.fetch, + "The content script should have access to window.pageFetch" + ); + + resolve(event.data); + }; + + window.addEventListener("message", listenPageMessage); + + window.postMessage({}, "*"); + } + ); + + const url = new URL("/return_headers.sjs", location).href; + + await Promise.all([ + new Promise((resolve, reject) => { + const req = new content.XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", () => + resolve(JSON.parse(req.responseText)) + ); + req.addEventListener("error", reject); + req.send(); + }), + content.fetch(url).then(res => res.json()), + ]) + .then(async ([xhrResult, fetchResult]) => { + browser.test.assertEq( + webpageFetchResult.referer, + fetchResult.referer, + "window.pageFetch referrer is the same of a webpage fetch request" + ); + browser.test.assertEq( + webpageFetchResult.origin, + fetchResult.origin, + "window.pageFetch origin is the same of a webpage fetch request" + ); + + browser.test.assertEq( + webpageXhrResult.referer, + xhrResult.referer, + "content.XMLHttpRequest referrer is the same of a webpage fetch request" + ); + }) + .catch(error => { + browser.test.fail(`Unexpected error: ${error}`); + }); + + browser.test.notifyPass("content-script-page-xhr"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://xpcshell.test/*"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": `(${contentScript})()`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://xpcshell.test/data/file_page_xhr.html" + ); + await extension.awaitFinish("content-script-page-xhr"); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js new file mode 100644 index 0000000000..385563bab2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js @@ -0,0 +1,845 @@ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +// ExtensionParent.jsm is being imported lazily because when it is imported Services.appinfo will be +// retrieved and cached (as a side-effect of Schemas.jsm being imported), and so Services.appinfo +// will not be returning the version set by AddonTestUtils.createAppInfo and this test will +// fail on non-nightly builds (because the cached appinfo.version will be undefined and +// AddonManager startup will fail). +ChromeUtils.defineModuleGetter( + this, + "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm" +); + +const BROWSER_PROPERTIES = "chrome://browser/locale/browser.properties"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +let sawPrompt = false; +let acceptPrompt = false; +const observer = { + observe(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + sawPrompt = true; + let { resolve } = subject.wrappedJSObject; + resolve(acceptPrompt); + } + }, +}; + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + true + ); + Services.obs.addObserver(observer, "webextension-optional-permission-prompt"); + registerCleanupFunction(() => { + Services.obs.removeObserver( + observer, + "webextension-optional-permission-prompt" + ); + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; +}); + +add_task(async function test_permissions_on_startup() { + let extensionId = "@permissionTest"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { id: extensionId }, + }, + permissions: ["tabs"], + }, + useAddonManager: "permanent", + async background() { + let perms = await browser.permissions.getAll(); + browser.test.sendMessage("permissions", perms); + }, + }); + let adding = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }; + await extension.startup(); + let perms = await extension.awaitMessage("permissions"); + equal(perms.permissions.length, 1, "one permission"); + equal(perms.permissions[0], "tabs", "internal permission not present"); + + const { StartupCache } = ExtensionParent; + + // StartupCache.permissions will not contain the extension permissions. + let manifestData = await StartupCache.permissions.get(extensionId, () => { + return { permissions: [], origins: [] }; + }); + equal(manifestData.permissions.length, 0, "no permission"); + + perms = await ExtensionPermissions.get(extensionId); + equal(perms.permissions.length, 0, "no permissions"); + await ExtensionPermissions.add(extensionId, adding); + + // Restart the extension and re-test the permissions. + await ExtensionPermissions._uninit(); + await AddonTestUtils.promiseRestartManager(); + let restarted = extension.awaitMessage("permissions"); + await extension.awaitStartup(); + perms = await restarted; + + manifestData = await StartupCache.permissions.get(extensionId, () => { + return { permissions: [], origins: [] }; + }); + deepEqual( + manifestData.permissions, + adding.permissions, + "StartupCache.permissions contains permission" + ); + + equal(perms.permissions.length, 1, "one permission"); + equal(perms.permissions[0], "tabs", "internal permission not present"); + let added = await ExtensionPermissions._get(extensionId); + deepEqual(added, adding, "permissions were retained"); + + await extension.unload(); +}); + +add_task(async function test_permissions() { + const REQUIRED_PERMISSIONS = ["downloads"]; + const REQUIRED_ORIGINS = ["*://site.com/", "*://*.domain.com/"]; + const REQUIRED_ORIGINS_NORMALIZED = ["*://site.com/*", "*://*.domain.com/*"]; + + const OPTIONAL_PERMISSIONS = ["idle", "clipboardWrite"]; + const OPTIONAL_ORIGINS = [ + "http://optionalsite.com/", + "https://*.optionaldomain.com/", + ]; + const OPTIONAL_ORIGINS_NORMALIZED = [ + "http://optionalsite.com/*", + "https://*.optionaldomain.com/*", + ]; + + function background() { + browser.test.onMessage.addListener(async (method, arg) => { + if (method == "getAll") { + let perms = await browser.permissions.getAll(); + let url = browser.extension.getURL("*"); + perms.origins = perms.origins.filter(i => i != url); + browser.test.sendMessage("getAll.result", perms); + } else if (method == "contains") { + let result = await browser.permissions.contains(arg); + browser.test.sendMessage("contains.result", result); + } else if (method == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", { + status: "success", + result, + }); + } catch (err) { + browser.test.sendMessage("request.result", { + status: "error", + message: err.message, + }); + } + } else if (method == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: [...REQUIRED_PERMISSIONS, ...REQUIRED_ORIGINS], + optional_permissions: [...OPTIONAL_PERMISSIONS, ...OPTIONAL_ORIGINS], + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + function call(method, arg) { + extension.sendMessage(method, arg); + return extension.awaitMessage(`${method}.result`); + } + + let result = await call("getAll"); + deepEqual(result.permissions, REQUIRED_PERMISSIONS); + deepEqual(result.origins, REQUIRED_ORIGINS_NORMALIZED); + + for (let perm of REQUIRED_PERMISSIONS) { + result = await call("contains", { permissions: [perm] }); + equal(result, true, `contains() returns true for fixed permission ${perm}`); + } + for (let origin of REQUIRED_ORIGINS) { + result = await call("contains", { origins: [origin] }); + equal(result, true, `contains() returns true for fixed origin ${origin}`); + } + + // None of the optional permissions should be available yet + for (let perm of OPTIONAL_PERMISSIONS) { + result = await call("contains", { permissions: [perm] }); + equal(result, false, `contains() returns false for permission ${perm}`); + } + for (let origin of OPTIONAL_ORIGINS) { + result = await call("contains", { origins: [origin] }); + equal(result, false, `contains() returns false for origin ${origin}`); + } + + result = await call("contains", { + permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS], + }); + equal( + result, + false, + "contains() returns false for a mix of available and unavailable permissions" + ); + + let perm = OPTIONAL_PERMISSIONS[0]; + result = await call("request", { permissions: [perm] }); + equal( + result.status, + "error", + "request() fails if not called from an event handler" + ); + ok( + /request may only be called from a user input handler/.test(result.message), + "error message for calling request() outside an event handler is reasonable" + ); + result = await call("contains", { permissions: [perm] }); + equal( + result, + false, + "Permission requested outside an event handler was not granted" + ); + + await withHandlingUserInput(extension, async () => { + result = await call("request", { permissions: ["notifications"] }); + equal( + result.status, + "error", + "request() for permission not in optional_permissions should fail" + ); + ok( + /since it was not declared in optional_permissions/.test(result.message), + "error message for undeclared optional_permission is reasonable" + ); + + // Check request() when the prompt is canceled. + acceptPrompt = false; + result = await call("request", { permissions: [perm] }); + equal(result.status, "success", "request() returned cleanly"); + equal( + result.result, + false, + "request() returned false for rejected permission" + ); + + result = await call("contains", { permissions: [perm] }); + equal(result, false, "Rejected permission was not granted"); + + // Call request() and accept the prompt + acceptPrompt = true; + let allOptional = { + permissions: OPTIONAL_PERMISSIONS, + origins: OPTIONAL_ORIGINS, + }; + result = await call("request", allOptional); + equal(result.status, "success", "request() returned cleanly"); + equal( + result.result, + true, + "request() returned true for accepted permissions" + ); + + // Verify that requesting a permission/origin in the wrong field fails + let originsAsPerms = { + permissions: OPTIONAL_ORIGINS, + }; + let permsAsOrigins = { + origins: OPTIONAL_PERMISSIONS, + }; + + result = await call("request", originsAsPerms); + equal( + result.status, + "error", + "Requesting an origin as a permission should fail" + ); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + "Error message for origin as permission is reasonable" + ); + + result = await call("request", permsAsOrigins); + equal( + result.status, + "error", + "Requesting a permission as an origin should fail" + ); + ok( + /Type error for parameter permissions \(Error processing origins/.test( + result.message + ), + "Error message for permission as origin is reasonable" + ); + }); + + let allPermissions = { + permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS], + origins: [...REQUIRED_ORIGINS_NORMALIZED, ...OPTIONAL_ORIGINS_NORMALIZED], + }; + + result = await call("getAll"); + deepEqual( + result, + allPermissions, + "getAll() returns required and runtime requested permissions" + ); + + result = await call("contains", allPermissions); + equal( + result, + true, + "contains() returns true for runtime requested permissions" + ); + + // Restart, verify permissions are still present + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + result = await call("getAll"); + deepEqual( + result, + allPermissions, + "Runtime requested permissions are still present after restart" + ); + + // Check remove() + result = await call("remove", { permissions: OPTIONAL_PERMISSIONS }); + equal(result, true, "remove() succeeded"); + + let perms = { + permissions: REQUIRED_PERMISSIONS, + origins: [...REQUIRED_ORIGINS_NORMALIZED, ...OPTIONAL_ORIGINS_NORMALIZED], + }; + result = await call("getAll"); + deepEqual(result, perms, "Expected permissions remain after removing some"); + + result = await call("remove", { origins: OPTIONAL_ORIGINS }); + equal(result, true, "remove() succeeded"); + + perms.origins = REQUIRED_ORIGINS_NORMALIZED; + result = await call("getAll"); + deepEqual(result, perms, "Back to default permissions after removing more"); + + await extension.unload(); +}); + +add_task(async function test_startup() { + async function background() { + browser.test.onMessage.addListener(async perms => { + await browser.permissions.request(perms); + browser.test.sendMessage("requested"); + }); + + let all = await browser.permissions.getAll(); + let url = browser.extension.getURL("*"); + all.origins = all.origins.filter(i => i != url); + browser.test.sendMessage("perms", all); + } + + const PERMS1 = { + permissions: ["clipboardRead", "tabs"], + }; + const PERMS2 = { + origins: ["https://site2.com/*"], + }; + + let extension1 = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: PERMS1.permissions, + }, + useAddonManager: "permanent", + }); + let extension2 = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: PERMS2.origins, + }, + useAddonManager: "permanent", + }); + + await extension1.startup(); + await extension2.startup(); + + let perms = await extension1.awaitMessage("perms"); + perms = await extension2.awaitMessage("perms"); + + await withHandlingUserInput(extension1, async () => { + extension1.sendMessage(PERMS1); + await extension1.awaitMessage("requested"); + }); + + await withHandlingUserInput(extension2, async () => { + extension2.sendMessage(PERMS2); + await extension2.awaitMessage("requested"); + }); + + // Restart everything, and force the permissions store to be + // re-read on startup + await ExtensionPermissions._uninit(); + await AddonTestUtils.promiseRestartManager(); + await extension1.awaitStartup(); + await extension2.awaitStartup(); + + async function checkPermissions(extension, permissions) { + perms = await extension.awaitMessage("perms"); + let expect = Object.assign({ permissions: [], origins: [] }, permissions); + deepEqual(perms, expect, "Extension got correct permissions on startup"); + } + + await checkPermissions(extension1, PERMS1); + await checkPermissions(extension2, PERMS2); + + await extension1.unload(); + await extension2.unload(); +}); + +// Test that we don't prompt for permissions an extension already has. +add_task(async function test_alreadyGranted() { + const REQUIRED_PERMISSIONS = [ + "geolocation", + "*://required-host.com/", + "*://*.required-domain.com/", + ]; + const OPTIONAL_PERMISSIONS = [ + ...REQUIRED_PERMISSIONS, + "clipboardRead", + "*://optional-host.com/", + "*://*.optional-domain.com/", + ]; + + function pageScript() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", result); + } else if (msg == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } else if (msg == "close") { + window.close(); + } + }); + + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + + manifest: { + permissions: REQUIRED_PERMISSIONS, + optional_permissions: OPTIONAL_PERMISSIONS, + }, + + files: { + "page.html": `<html><head> + <script src="page.js"><\/script> + </head></html>`, + + "page.js": pageScript, + }, + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + let url = await extension.awaitMessage("ready"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-ready"); + + async function checkRequest(arg, expectPrompt, msg) { + sawPrompt = false; + extension.sendMessage("request", arg); + let result = await extension.awaitMessage("request.result"); + ok(result, "request() call succeeded"); + equal( + sawPrompt, + expectPrompt, + `Got ${expectPrompt ? "" : "no "}permission prompt for ${msg}` + ); + } + + await checkRequest( + { permissions: ["geolocation"] }, + false, + "required permission from manifest" + ); + await checkRequest( + { origins: ["http://required-host.com/"] }, + false, + "origin permission from manifest" + ); + await checkRequest( + { origins: ["http://host.required-domain.com/"] }, + false, + "wildcard origin permission from manifest" + ); + + await checkRequest( + { permissions: ["clipboardRead"] }, + true, + "optional permission" + ); + await checkRequest( + { permissions: ["clipboardRead"] }, + false, + "already granted optional permission" + ); + + await checkRequest( + { origins: ["http://optional-host.com/"] }, + true, + "optional origin" + ); + await checkRequest( + { origins: ["http://optional-host.com/"] }, + false, + "already granted origin permission" + ); + + await checkRequest( + { origins: ["http://*.optional-domain.com/"] }, + true, + "optional wildcard origin" + ); + await checkRequest( + { origins: ["http://*.optional-domain.com/"] }, + false, + "already granted optional wildcard origin" + ); + await checkRequest( + { origins: ["http://host.optional-domain.com/"] }, + false, + "host matching optional wildcard origin" + ); + await page.close(); + }); + + await extension.unload(); +}); + +// IMPORTANT: Do not change this list without review from a Web Extensions peer! + +const GRANTED_WITHOUT_USER_PROMPT = [ + "activeTab", + "activityLog", + "alarms", + "captivePortal", + "contextMenus", + "contextualIdentities", + "cookies", + "dns", + "geckoProfiler", + "identity", + "idle", + "menus", + "menus.overrideContext", + "mozillaAddons", + "networkStatus", + "normandyAddonStudy", + "search", + "storage", + "telemetry", + "theme", + "unlimitedStorage", + "urlbar", + "webRequest", + "webRequestBlocking", +]; + +add_task(function test_permissions_have_localization_strings() { + let noPromptNames = Schemas.getPermissionNames([ + "PermissionNoPrompt", + "OptionalPermissionNoPrompt", + ]); + Assert.deepEqual( + GRANTED_WITHOUT_USER_PROMPT, + noPromptNames, + "List of no-prompt permissions is correct." + ); + + const bundle = Services.strings.createBundle(BROWSER_PROPERTIES); + + for (const perm of Schemas.getPermissionNames()) { + try { + const str = bundle.GetStringFromName(`webextPerms.description.${perm}`); + + ok(str.length, `Found localization string for '${perm}' permission`); + } catch (e) { + ok( + GRANTED_WITHOUT_USER_PROMPT.includes(perm), + `Permission '${perm}' intentionally granted without prompting the user` + ); + } + } +}); + +// Check <all_urls> used as an optional API permission. +add_task(async function test_optional_all_urls() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + optional_permissions: ["<all_urls>"], + }, + + background() { + browser.test.onMessage.addListener(async () => { + let before = !!browser.tabs.captureVisibleTab; + let granted = await browser.permissions.request({ + origins: ["<all_urls>"], + }); + let after = !!browser.tabs.captureVisibleTab; + + browser.test.sendMessage("results", [before, granted, after]); + }); + }, + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + let [before, granted, after] = await extension.awaitMessage("results"); + + equal( + before, + false, + "captureVisibleTab() unavailable before optional permission request()" + ); + equal(granted, true, "request() for optional permissions granted"); + equal( + after, + true, + "captureVisibleTab() available after optional permission request()" + ); + }); + + await extension.unload(); +}); + +// Check that optional permissions are not included in update prompts +add_task(async function test_permissions_prompt() { + function background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("result", result); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version: 2, + version: "1.0", + + permissions: ["tabs", "https://test1.example.com/*"], + optional_permissions: ["clipboardWrite", "<all_urls>"], + + content_scripts: [ + { + matches: ["https://test2.example.com/*"], + js: [], + }, + ], + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + permissions: ["clipboardWrite"], + origins: ["https://test2.example.com/*"], + }); + let result = await extension.awaitMessage("result"); + equal(result, true, "request() for optional permissions succeeded"); + }); + + const PERMS = ["history", "tabs"]; + const ORIGINS = ["https://test1.example.com/*", "https://test3.example.com/"]; + let xpi = AddonTestUtils.createTempWebExtensionFile({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version: 2, + version: "2.0", + + applications: { gecko: { id: extension.id } }, + + permissions: [...PERMS, ...ORIGINS], + optional_permissions: ["clipboardWrite", "<all_urls>"], + }, + }); + + let install = await AddonManager.getInstallForFile(xpi); + + Services.prefs.setBoolPref("extensions.webextPermissionPrompts", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextPermissionPrompts"); + }); + + let perminfo; + install.promptHandler = info => { + perminfo = info; + return Promise.resolve(); + }; + + await AddonTestUtils.promiseCompleteInstall(install); + await extension.awaitStartup(); + + notEqual(perminfo, undefined, "Permission handler was invoked"); + let perms = perminfo.addon.userPermissions; + deepEqual( + perms.permissions, + PERMS, + "Update details includes only manifest api permissions" + ); + deepEqual( + perms.origins, + ORIGINS, + "Update details includes only manifest origin permissions" + ); + + await extension.unload(); +}); + +// Check that internal permissions can not be set and are not returned by the API. +add_task(async function test_internal_permissions() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + function background() { + browser.test.onMessage.addListener(async (method, arg) => { + try { + if (method == "getAll") { + let perms = await browser.permissions.getAll(); + browser.test.sendMessage("getAll.result", perms); + } else if (method == "contains") { + let result = await browser.permissions.contains(arg); + browser.test.sendMessage("contains.result", { + status: "success", + result, + }); + } else if (method == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", { + status: "success", + result, + }); + } else if (method == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } + } catch (err) { + browser.test.sendMessage(`${method}.result`, { + status: "error", + message: err.message, + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version: 2, + version: "1.0", + permissions: [], + }, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }); + + let perm = "internal:privateBrowsingAllowed"; + + await extension.startup(); + + function call(method, arg) { + extension.sendMessage(method, arg); + return extension.awaitMessage(`${method}.result`); + } + + let result = await call("getAll"); + ok(!result.permissions.includes(perm), "internal not returned"); + + result = await call("contains", { permissions: [perm] }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to check for internal permission: ${result.message}` + ); + + result = await call("remove", { permissions: [perm] }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to remove for internal permission ${result.message}` + ); + + await withHandlingUserInput(extension, async () => { + result = await call("request", { + permissions: [perm], + origins: [], + }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to request internal permission ${result.message}` + ); + }); + + await extension.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js new file mode 100644 index 0000000000..910aef6df7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js @@ -0,0 +1,397 @@ +"use strict"; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +let OptionalPermissions; + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; + + // We want to get a list of optional permissions prior to loading an extension, + // so we'll get ExtensionParent to do that for us. + await ExtensionParent.apiManager.lazyInit(); + + // These permissions have special behaviors and/or are not mapped directly to an + // api namespace. They will have their own tests for specific behavior. + let ignore = [ + "activeTab", + "clipboardRead", + "clipboardWrite", + "devtools", + "downloads.open", + "geolocation", + "management", + "menus.overrideContext", + "search", + "tabHide", + "tabs", + "webRequestBlocking", + ]; + OptionalPermissions = Schemas.getPermissionNames([ + "OptionalPermission", + "OptionalPermissionNoPrompt", + ]).filter(n => !ignore.includes(n)); +}); + +add_task(async function test_api_on_permissions_changed() { + async function background() { + let manifest = browser.runtime.getManifest(); + let permObj = { permissions: manifest.optional_permissions, origins: [] }; + + function verifyPermissions(enabled) { + for (let perm of manifest.optional_permissions) { + browser.test.assertEq( + enabled, + !!browser[perm], + `${perm} API is ${ + enabled ? "injected" : "removed" + } after permission request` + ); + } + } + + browser.permissions.onAdded.addListener(details => { + browser.test.assertEq( + JSON.stringify(details.permissions), + JSON.stringify(manifest.optional_permissions), + "expected permissions added" + ); + verifyPermissions(true); + browser.test.sendMessage("added"); + }); + + browser.permissions.onRemoved.addListener(details => { + browser.test.assertEq( + JSON.stringify(details.permissions), + JSON.stringify(manifest.optional_permissions), + "expected permissions removed" + ); + verifyPermissions(false); + browser.test.sendMessage("removed"); + }); + + browser.test.onMessage.addListener((msg, enabled) => { + if (msg === "request") { + browser.permissions.request(permObj); + } else if (msg === "verify_access") { + verifyPermissions(enabled); + browser.test.sendMessage("verified"); + } else if (msg === "revoke") { + browser.permissions.remove(permObj); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: OptionalPermissions, + }, + useAddonManager: "permanent", + }); + await extension.startup(); + + function addPermissions() { + extension.sendMessage("request"); + return extension.awaitMessage("added"); + } + + function removePermissions() { + extension.sendMessage("revoke"); + return extension.awaitMessage("removed"); + } + + function verifyPermissions(enabled) { + extension.sendMessage("verify_access", enabled); + return extension.awaitMessage("verified"); + } + + await withHandlingUserInput(extension, async () => { + await addPermissions(); + await removePermissions(); + await addPermissions(); + }); + + // reset handlingUserInput for the restart + extensionHandlers.delete(extension); + + // Verify access on restart + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + await verifyPermissions(true); + + await withHandlingUserInput(extension, async () => { + await removePermissions(); + }); + + // Add private browsing to be sure it doesn't come through. + let permObj = { + permissions: OptionalPermissions.concat("internal:privateBrowsingAllowed"), + origins: [], + }; + + // enable the permissions while the addon is running + await ExtensionPermissions.add(extension.id, permObj, extension.extension); + await extension.awaitMessage("added"); + await verifyPermissions(true); + + // disable the permissions while the addon is running + await ExtensionPermissions.remove(extension.id, permObj, extension.extension); + await extension.awaitMessage("removed"); + await verifyPermissions(false); + + // Add private browsing to test internal permission. If it slips through, + // we would get an error for an additional added message. + await ExtensionPermissions.add( + extension.id, + { permissions: ["internal:privateBrowsingAllowed"], origins: [] }, + extension.extension + ); + + // disable the addon and re-test revoking permissions. + await withHandlingUserInput(extension, async () => { + await addPermissions(); + }); + let addon = await AddonManager.getAddonByID(extension.id); + await addon.disable(); + await ExtensionPermissions.remove(extension.id, permObj); + await addon.enable(); + await extension.awaitStartup(); + + await verifyPermissions(false); + let perms = await ExtensionPermissions.get(extension.id); + equal(perms.permissions.length, 0, "no permissions on startup"); + + await extension.unload(); +}); + +add_task(async function test_geo_permissions() { + async function background() { + const permObj = { permissions: ["geolocation"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + let result = await browser.permissions.contains(permObj); + browser.test.sendMessage("done", result); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: "geo-test@test" } }, + optional_permissions: ["geolocation"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed on install" + ); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + ok(await extension.awaitMessage("done"), "permission granted"); + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.ALLOW_ACTION, + "geolocation allowed after requested" + ); + + extension.sendMessage("remove"); + ok(!(await extension.awaitMessage("done")), "permission revoked"); + + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed after removed" + ); + + // re-grant to test update removal + extension.sendMessage("request"); + ok(await extension.awaitMessage("done"), "permission granted"); + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.ALLOW_ACTION, + "geolocation allowed after re-requested" + ); + }); + + // We should not have geo permission after this upgrade. + await extension.upgrade({ + manifest: { + applications: { gecko: { id: "geo-test@test" } }, + }, + useAddonManager: "permanent", + }); + + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed after upgrade" + ); + + await extension.unload(); +}); + +add_task(async function test_browserSetting_permissions() { + async function background() { + const permObj = { permissions: ["browserSettings"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + await browser.browserSettings.cacheEnabled.set({ value: false }); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + browser.test.sendMessage("done"); + }); + } + + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["browserSettings"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + ok(cacheIsEnabled(), "setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(!cacheIsEnabled(), "setting was set after request"); + + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(cacheIsEnabled(), "setting is reset after remove"); + + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(!cacheIsEnabled(), "setting was set after request"); + }); + + await ExtensionPermissions._uninit(); + extensionHandlers.delete(extension); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(cacheIsEnabled(), "setting is reset after remove"); + }); + + await extension.unload(); +}); + +add_task(async function test_privacy_permissions() { + async function background() { + const permObj = { permissions: ["privacy"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + await browser.privacy.websites.trackingProtectionMode.set({ + value: "always", + }); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + browser.test.sendMessage("done"); + }); + } + + function hasSetting() { + return Services.prefs.getBoolPref("privacy.trackingprotection.enabled"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["privacy"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + ok(!hasSetting(), "setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(hasSetting(), "setting was set after request"); + + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(!hasSetting(), "setting is reset after remove"); + + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(hasSetting(), "setting was set after request"); + }); + + await ExtensionPermissions._uninit(); + extensionHandlers.delete(extension); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(!hasSetting(), "setting is reset after remove"); + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js new file mode 100644 index 0000000000..4b9dccf7b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js @@ -0,0 +1,252 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; +}); + +add_task(async function test_migrated_permission_to_optional() { + let id = "permission-upgrade@test"; + let extensionData = { + manifest: { + version: "1.0", + applications: { gecko: { id } }, + permissions: [ + "webRequest", + "tabs", + "http://example.net/*", + "http://example.com/*", + ], + }, + useAddonManager: "permanent", + }; + + function checkPermissions() { + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("webRequest"), "addon has webRequest permission"); + ok(policy.hasPermission("tabs"), "addon has tabs permission"); + ok( + policy.canAccessURI(Services.io.newURI("http://example.net/")), + "addon has example.net host permission" + ); + ok( + policy.canAccessURI(Services.io.newURI("http://example.com/")), + "addon has example.com host permission" + ); + ok( + !policy.canAccessURI(Services.io.newURI("http://other.com/")), + "addon does not have other.com host permission" + ); + } + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + checkPermissions(); + + // Move to using optional permission + extensionData.manifest.version = "2.0"; + extensionData.manifest.permissions = ["tabs", "http://example.net/*"]; + extensionData.manifest.optional_permissions = [ + "webRequest", + "http://example.com/*", + "http://other.com/*", + ]; + + // Restart the addon manager to flush the AddonInternal instance created + // when installing the addon above. See bug 1622117. + await AddonTestUtils.promiseRestartManager(); + await extension.upgrade(extensionData); + + equal(extension.version, "2.0", "Expected extension version"); + checkPermissions(); + + await extension.unload(); +}); + +// This tests that settings are removed if a required permission is removed. +// We use two settings APIs to make sure the one we keep permission to is not +// removed inadvertantly. +add_task(async function test_required_permissions_removed() { + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extData = { + background() { + if (browser.browserSettings) { + browser.browserSettings.cacheEnabled.set({ value: false }); + } + browser.privacy.services.passwordSavingEnabled.set({ value: false }); + }, + manifest: { + applications: { gecko: { id: "pref-test@test" } }, + permissions: ["tabs", "browserSettings", "privacy", "http://test.com/*"], + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + ok( + Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting intial value as expected" + ); + await extension.startup(); + ok(!cacheIsEnabled(), "setting is set after startup"); + + extData.manifest.permissions = ["tabs"]; + extData.manifest.optional_permissions = ["privacy"]; + await extension.upgrade(extData); + ok(cacheIsEnabled(), "setting is reset after upgrade"); + ok( + !Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting is still set after upgrade" + ); + + await extension.unload(); +}); + +// This tests that settings are removed if a granted permission is removed. +// We use two settings APIs to make sure the one we keep permission to is not +// removed inadvertantly. +add_task(async function test_granted_permissions_removed() { + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extData = { + async background() { + browser.test.onMessage.addListener(async msg => { + await browser.permissions.request({ permissions: msg.permissions }); + if (browser.browserSettings) { + browser.browserSettings.cacheEnabled.set({ value: false }); + } + browser.privacy.services.passwordSavingEnabled.set({ value: false }); + browser.test.sendMessage("done"); + }); + }, + // "tabs" is never granted, it is included to exercise the removal code + // that called during the upgrade. + manifest: { + applications: { gecko: { id: "pref-test@test" } }, + optional_permissions: [ + "tabs", + "browserSettings", + "privacy", + "http://test.com/*", + ], + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + ok( + Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting intial value as expected" + ); + await extension.startup(); + await withHandlingUserInput(extension, async () => { + extension.sendMessage({ permissions: ["browserSettings", "privacy"] }); + await extension.awaitMessage("done"); + }); + ok(!cacheIsEnabled(), "setting is set after startup"); + + extData.manifest.permissions = ["privacy"]; + delete extData.manifest.optional_permissions; + await extension.upgrade(extData); + ok(cacheIsEnabled(), "setting is reset after upgrade"); + ok( + !Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting is still set after upgrade" + ); + + await extension.unload(); +}); + +// Test an update where an add-on becomes a theme. +add_task(async function test_addon_to_theme_update() { + let id = "theme-test@test"; + let extData = { + manifest: { + applications: { gecko: { id } }, + version: "1.0", + optional_permissions: ["tabs"], + }, + async background() { + browser.test.onMessage.addListener(async msg => { + await browser.permissions.request({ permissions: msg.permissions }); + browser.test.sendMessage("done"); + }); + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage({ permissions: ["tabs"] }); + await extension.awaitMessage("done"); + }); + + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("tabs"), "addon has tabs permission"); + + await extension.upgrade({ + manifest: { + applications: { gecko: { id } }, + version: "2.0", + theme: { + images: { + theme_frame: "image1.png", + }, + }, + }, + useAddonManager: "permanent", + }); + // When a theme is installed, it starts off in disabled mode, as seen in + // toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js . + // But if we upgrade from an enabled extension, the theme is enabled. + equal(extension.addon.userDisabled, false, "Theme is enabled"); + + policy = WebExtensionPolicy.getByID(id); + ok(!policy.hasPermission("tabs"), "addon tabs permission was removed"); + let perms = await ExtensionPermissions._get(id); + ok(!perms?.permissions?.length, "no retained permissions"); + + extData.manifest.version = "3.0"; + extData.manifest.permissions = ["privacy"]; + await extension.upgrade(extData); + + policy = WebExtensionPolicy.getByID(id); + ok(!policy.hasPermission("tabs"), "addon tabs permission not added"); + ok(policy.hasPermission("privacy"), "addon privacy permission added"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js new file mode 100644 index 0000000000..917a609e32 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js @@ -0,0 +1,160 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +const observer = { + observe(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + let { resolve } = subject.wrappedJSObject; + resolve(true); + } + }, +}; + +// Look up the cached permissions, if any. +async function getCachedPermissions(extensionId) { + const NotFound = Symbol("extension ID not found in permissions cache"); + try { + return await ExtensionParent.StartupCache.permissions.get( + extensionId, + () => { + // Throw error to prevent the key from being created. + throw NotFound; + } + ); + } catch (e) { + if (e === NotFound) { + return null; + } + throw e; + } +} + +// Look up the permissions from the file. Internal methods are used to avoid +// inadvertently changing the permissions in the cache or the database. +async function getStoredPermissions(extensionId) { + if (await ExtensionPermissions._has(extensionId)) { + return ExtensionPermissions._get(extensionId); + } + return null; +} + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + true + ); + Services.obs.addObserver(observer, "webextension-optional-permission-prompt"); + await AddonTestUtils.promiseStartupManager(); + registerCleanupFunction(async () => { + await AddonTestUtils.promiseShutdownManager(); + Services.obs.removeObserver( + observer, + "webextension-optional-permission-prompt" + ); + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); +}); + +// This test must run before any restart of the addonmanager so the +// ExtensionAddonObserver works. +add_task(async function test_permissions_removed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + optional_permissions: ["idle"], + }, + background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", result); + } catch (err) { + browser.test.sendMessage("request.result", err.message); + } + } + }); + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { permissions: ["idle"], origins: [] }); + let result = await extension.awaitMessage("request.result"); + equal(result, true, "request() for optional permissions succeeded"); + }); + + let id = extension.id; + let perms = await ExtensionPermissions.get(id); + equal(perms.permissions.length, 1, "optional permission added"); + + Assert.deepEqual( + await getCachedPermissions(id), + { + permissions: ["idle"], + origins: [], + }, + "Optional permission added to cache" + ); + Assert.deepEqual( + await getStoredPermissions(id), + { + permissions: ["idle"], + origins: [], + }, + "Optional permission added to persistent file" + ); + + await extension.unload(); + + // Directly read from the internals instead of using ExtensionPermissions.get, + // because the latter will lazily cache the extension ID. + Assert.deepEqual( + await getCachedPermissions(id), + null, + "Cached permissions removed" + ); + Assert.deepEqual( + await getStoredPermissions(id), + null, + "Stored permissions removed" + ); + + perms = await ExtensionPermissions.get(id); + equal(perms.permissions.length, 0, "no permissions after uninstall"); + equal(perms.origins.length, 0, "no origin permissions after uninstall"); + + // The public ExtensionPermissions.get method should not store (empty) + // permissions in the persistent database. Polluting the cache is not ideal, + // but acceptable since the cache will eventually be cleared, and non-test + // code is not likely to call ExtensionPermissions.get() for non-installed + // extensions anyway. + Assert.deepEqual(await getCachedPermissions(id), perms, "Permissions cached"); + Assert.deepEqual( + await getStoredPermissions(id), + null, + "Permissions not saved" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js new file mode 100644 index 0000000000..7acb383053 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js @@ -0,0 +1,521 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { ExtensionAPI } = ExtensionCommon; + +const SCHEMA = [ + { + namespace: "eventtest", + events: [ + { + name: "onEvent1", + type: "function", + extraParameters: [{ type: "any" }], + }, + { + name: "onEvent2", + type: "function", + extraParameters: [{ type: "any" }], + }, + ], + }, +]; + +// The code in this class does not actually run in this test scope, it is +// serialized into a string which is later loaded by the WebExtensions +// framework in the same context as other extension APIs. By writing it +// this way rather than as a big string constant we get lint coverage. +// But eslint doesn't understand that this code runs in a different context +// where the EventManager class is available so just tell it here: +/* global EventManager */ +const API = class extends ExtensionAPI { + primeListener(extension, event, fire, params) { + Services.obs.notifyObservers( + { event, fire, params }, + "prime-event-listener" + ); + + const FIRE_TOPIC = `fire-${event}`; + + async function listener(subject, topic, data) { + try { + if (subject.wrappedJSObject.waitForBackground) { + await fire.wakeup(); + } + await fire.async(subject.wrappedJSObject.listenerArgs); + } catch (err) { + let errSubject = { event, errorMessage: err.toString() }; + Services.obs.notifyObservers(errSubject, "listener-callback-exception"); + } + } + Services.obs.addObserver(listener, FIRE_TOPIC); + + return { + unregister() { + Services.obs.notifyObservers( + { event, params }, + "unregister-primed-listener" + ); + Services.obs.removeObserver(listener, FIRE_TOPIC); + }, + convert(_fire) { + Services.obs.notifyObservers( + { event, params }, + "convert-event-listener" + ); + fire = _fire; + }, + }; + } + + getAPI(context) { + return { + eventtest: { + onEvent1: new EventManager({ + context, + name: "test.event1", + persistent: { + module: "eventtest", + event: "onEvent1", + }, + register: (fire, ...params) => { + let data = { event: "onEvent1", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + + onEvent2: new EventManager({ + context, + name: "test.event1", + persistent: { + module: "eventtest", + event: "onEvent2", + }, + register: (fire, ...params) => { + let data = { event: "onEvent2", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + }, + }; + } +}; + +const API_SCRIPT = `this.eventtest = ${API.toString()}`; + +const MODULE_INFO = { + eventtest: { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["eventtest"]], + url: URL.createObjectURL(new Blob([API_SCRIPT])), + }, +}; + +const global = this; + +// Wait for the given event (topic) to occur a specific number of times +// (count). If fn is not supplied, the Promise returned from this function +// resolves as soon as that many instances of the event have been observed. +// If fn is supplied, this function also waits for the Promise that fn() +// returns to complete and ensures that the given event does not occur more +// than `count` times before then. On success, resolves with an array +// of the subjects from each of the observed events. +async function promiseObservable(topic, count, fn = null) { + let _countResolve; + let results = []; + function listener(subject, _topic, data) { + results.push(subject.wrappedJSObject); + if (results.length > count) { + ok(false, `Got unexpected ${topic} event`); + } else if (results.length == count) { + _countResolve(); + } + } + Services.obs.addObserver(listener, topic); + + try { + await Promise.all([ + new Promise(resolve => { + _countResolve = resolve; + }), + fn && fn(), + ]); + } finally { + Services.obs.removeObserver(listener, topic); + } + + return results; +} + +add_task(async function setup() { + Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true + ); + + AddonTestUtils.init(global); + AddonTestUtils.overrideCertDB(); + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" + ); + + ExtensionParent.apiManager.registerModules(MODULE_INFO); +}); + +add_task(async function test_persistent_events() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let register1 = true, + register2 = true; + if (localStorage.getItem("skip1")) { + register1 = false; + } + if (localStorage.getItem("skip2")) { + register2 = false; + } + + let listener1 = arg => browser.test.sendMessage("listener1", arg); + let listener2 = arg => browser.test.sendMessage("listener2", arg); + let listener3 = arg => browser.test.sendMessage("listener3", arg); + + if (register1) { + browser.eventtest.onEvent1.addListener(listener1, "listener1"); + } + if (register2) { + browser.eventtest.onEvent1.addListener(listener2, "listener2"); + browser.eventtest.onEvent2.addListener(listener3, "listener3"); + } + + browser.test.onMessage.addListener(msg => { + if (msg == "unregister2") { + browser.eventtest.onEvent2.removeListener(listener3); + localStorage.setItem("skip2", true); + } else if (msg == "unregister1") { + localStorage.setItem("skip1", true); + browser.test.sendMessage("unregistered"); + } + }); + + browser.test.sendMessage("ready"); + }, + }); + + function check( + info, + what, + { listener1 = true, listener2 = true, listener3 = true } = {} + ) { + let count = (listener1 ? 1 : 0) + (listener2 ? 1 : 0) + (listener3 ? 1 : 0); + equal(info.length, count, `Got ${count} ${what} events`); + + let i = 0; + if (listener1) { + equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 1`); + deepEqual( + info[i].params, + ["listener1"], + `Got event1 ${what} args for listener 1` + ); + ++i; + } + + if (listener2) { + equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 2`); + deepEqual( + info[i].params, + ["listener2"], + `Got event1 ${what} args for listener 2` + ); + ++i; + } + + if (listener3) { + equal(info[i].event, "onEvent2", `Got ${what} on event2 for listener 3`); + deepEqual( + info[i].params, + ["listener3"], + `Got event2 ${what} args for listener 3` + ); + ++i; + } + } + + // Check that the regular event registration process occurs when + // the extension is installed. + let [info] = await Promise.all([ + promiseObservable("register-event-listener", 3), + extension.startup(), + ]); + check(info, "register"); + + await extension.awaitMessage("ready"); + + // Check that the regular unregister process occurs when + // the browser shuts down. + [info] = await Promise.all([ + promiseObservable("unregister-event-listener", 3), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + check(info, "unregister"); + + // Check that listeners are primed at the next browser startup. + [info] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(info, "prime"); + + // Check that primed listeners are converted to regular listeners + // when the background page is started after browser startup. + let p = promiseObservable("convert-event-listener", 3); + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + info = await p; + + check(info, "convert"); + + await extension.awaitMessage("ready"); + + // Check that when the event is triggered, all the plumbing worked + // correctly for the primed-then-converted listener. + let listenerArgs = { test: "kaboom" }; + Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent1"); + + let details = await extension.awaitMessage("listener1"); + deepEqual(details, listenerArgs, "Listener 1 fired"); + details = await extension.awaitMessage("listener2"); + deepEqual(details, listenerArgs, "Listener 2 fired"); + + // Check that the converted listener is properly unregistered at + // browser shutdown. + [info] = await Promise.all([ + promiseObservable("unregister-primed-listener", 3), + AddonTestUtils.promiseShutdownManager(), + ]); + check(info, "unregister"); + + // Start up again, listener should be primed + [info] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(info, "prime"); + + // Check that triggering the event before the listener has been converted + // causes the background page to be loaded and the listener to be converted, + // and the listener is invoked. + p = promiseObservable("convert-event-listener", 3); + listenerArgs.test = "startup event"; + Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent2"); + info = await p; + + check(info, "convert"); + + details = await extension.awaitMessage("listener3"); + deepEqual(details, listenerArgs, "Listener 3 fired for event during startup"); + + await extension.awaitMessage("ready"); + + // Check that the unregister process works when we manually remove + // a listener. + p = promiseObservable("unregister-primed-listener", 1); + extension.sendMessage("unregister2"); + info = await p; + check(info, "unregister", { listener1: false, listener2: false }); + + // Check that we only get unregisters for the remaining events after + // one listener has been removed. + info = await promiseObservable("unregister-primed-listener", 2, () => + AddonTestUtils.promiseShutdownManager() + ); + check(info, "unregister", { listener3: false }); + + // Check that after restart, only listeners that were present at + // the end of the last session are primed. + info = await promiseObservable("prime-event-listener", 2, () => + AddonTestUtils.promiseStartupManager() + ); + check(info, "prime", { listener3: false }); + + // Check that if the background script does not re-register listeners, + // the primed listeners are unregistered after the background page + // starts up. + p = promiseObservable("unregister-primed-listener", 1, () => + extension.awaitMessage("ready") + ); + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + info = await p; + check(info, "unregister", { listener1: false, listener3: false }); + + // Just listener1 should be registered now, fire event1 to confirm. + listenerArgs.test = "third time"; + Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent1"); + details = await extension.awaitMessage("listener1"); + deepEqual(details, listenerArgs, "Listener 1 fired"); + + // Tell the extension not to re-register listener1 on the next startup + extension.sendMessage("unregister1"); + await extension.awaitMessage("unregistered"); + + // Shut down, start up + info = await promiseObservable("unregister-primed-listener", 1, () => + AddonTestUtils.promiseShutdownManager() + ); + check(info, "unregister", { listener2: false, listener3: false }); + + info = await promiseObservable("prime-event-listener", 1, () => + AddonTestUtils.promiseStartupManager() + ); + check(info, "register", { listener2: false, listener3: false }); + + // Check that firing event1 causes the listener fire callback to + // reject. + p = promiseObservable("listener-callback-exception", 1); + Services.obs.notifyObservers( + { listenerArgs, waitForBackground: true }, + "fire-onEvent1" + ); + equal( + (await p)[0].errorMessage, + "Error: primed listener not re-registered", + "Primed listener that was not re-registered received an error when event was triggered during startup" + ); + + await extension.awaitMessage("ready"); + + await extension.unload(); + + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test checks whether primed listeners are correctly unregistered when +// a background page load is interrupted. In particular, it verifies that the +// fire.wakeup() and fire.async() promises settle eventually. +add_task(async function test_shutdown_before_background_loaded() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.eventtest.onEvent1.addListener(listener, "triggered"); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 1), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + + await Promise.all([ + promiseObservable("unregister-event-listener", 1), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + + let primeListenerPromise = promiseObservable("prime-event-listener", 1); + let fire; + let fireWakeupBeforeBgFail; + let fireAsyncBeforeBgFail; + + let bgAbortedPromise = new Promise(resolve => { + let Management = ExtensionParent.apiManager; + Management.once("extension-browser-inserted", (eventName, browser) => { + browser.loadURI = async () => { + // The fire.wakeup/fire.async promises created while loading the + // background page should settle when the page fails to load. + fire = (await primeListenerPromise)[0].fire; + fireWakeupBeforeBgFail = fire.wakeup(); + fireAsyncBeforeBgFail = fire.async(); + + extension.extension.once("background-page-aborted", resolve); + info("Forcing the background load to fail"); + browser.remove(); + }; + }); + }); + + let unregisterPromise = promiseObservable("unregister-primed-listener", 1); + + await Promise.all([ + primeListenerPromise, + AddonTestUtils.promiseStartupManager(), + ]); + await bgAbortedPromise; + info("Loaded extension and aborted load of background page"); + + await unregisterPromise; + info("Primed listener has been unregistered"); + + await fireWakeupBeforeBgFail; + info("fire.wakeup() before background load failure should settle"); + + await Assert.rejects( + fireAsyncBeforeBgFail, + /Error: listener not re-registered/, + "fire.async before background load failure should be rejected" + ); + + await fire.wakeup(); + info("fire.wakeup() after background load failure should settle"); + + await Assert.rejects( + fire.async(), + /Error: primed listener not re-registered/, + "fire.async after background load failure should be rejected" + ); + + await AddonTestUtils.promiseShutdownManager(); + + // End of the abnormal shutdown test. Now restart the extension to verify + // that the persistent listeners have not been unregistered. + + // Suppress background page start until an explicit notification. + ExtensionParent._resetStartupPromises(); + await Promise.all([ + promiseObservable("prime-event-listener", 1), + AddonTestUtils.promiseStartupManager(), + ]); + info("Triggering persistent event to force the background page to start"); + Services.obs.notifyObservers({ listenerArgs: 123 }, "fire-onEvent1"); + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + await Promise.all([ + promiseObservable("unregister-primed-listener", 1), + AddonTestUtils.promiseShutdownManager(), + ]); + + // And lastly, verify that a primed listener is correctly removed when the + // extension unloads normally before the delayed background page can load. + ExtensionParent._resetStartupPromises(); + await Promise.all([ + promiseObservable("prime-event-listener", 1), + AddonTestUtils.promiseStartupManager(), + ]); + + info("Unloading extension before background page has loaded"); + await Promise.all([ + promiseObservable("unregister-primed-listener", 1), + extension.unload(), + ]); + + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js new file mode 100644 index 0000000000..14a18b8fac --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js @@ -0,0 +1,964 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// Currently security.tls.version.min has a different default +// value in Nightly and Beta as opposed to Release builds. +const tlsMinPref = Services.prefs.getIntPref("security.tls.version.min"); +if (tlsMinPref != 1 && tlsMinPref != 3) { + ok(false, "This test expects security.tls.version.min set to 1 or 3."); +} +const tlsMinVer = tlsMinPref === 3 ? "TLSv1.2" : "TLSv1"; + +add_task(async function test_privacy() { + // Create an object to hold the values to which we will initialize the prefs. + const SETTINGS = { + "network.networkPredictionEnabled": { + "network.predictor.enabled": true, + "network.prefetch-next": true, + // This pref starts with a numerical value and we need to use whatever the + // default is or we encounter issues when the pref is reset during the test. + "network.http.speculative-parallel-limit": ExtensionPreferencesManager.getDefaultValue( + "network.http.speculative-parallel-limit" + ), + "network.dns.disablePrefetch": false, + }, + "websites.hyperlinkAuditingEnabled": { + "browser.send_pings": true, + }, + }; + + async function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let data = args[0]; + // The second argument is the end of the api name, + // e.g., "network.networkPredictionEnabled". + let apiObj = args[1].split(".").reduce((o, i) => o[i], browser.privacy); + let settingData; + switch (msg) { + case "get": + settingData = await apiObj.get(data); + browser.test.sendMessage("gotData", settingData); + break; + + case "set": + await apiObj.set(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("afterSet", settingData); + break; + + case "clear": + await apiObj.clear(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("afterClear", settingData); + break; + } + }); + } + + // Set prefs to our initial values. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.set(pref, SETTINGS[setting][pref]); + } + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.reset(pref); + } + } + }); + + await promiseStartupManager(); + + // Create an array of extensions to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + for (let setting in SETTINGS) { + testExtensions[0].sendMessage("get", {}, setting); + let data = await testExtensions[0].awaitMessage("gotData"); + ok(data.value, "get returns expected value."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "get returns expected levelOfControl." + ); + + testExtensions[0].sendMessage("get", { incognito: true }, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(data.value, "get returns expected value with incognito."); + equal( + data.levelOfControl, + "not_controllable", + "get returns expected levelOfControl with incognito." + ); + + // Change the value to false. + testExtensions[0].sendMessage("set", { value: false }, setting); + data = await testExtensions[0].awaitMessage("afterSet"); + ok(!data.value, "get returns expected value after setting."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Verify the prefs have been set to match the "false" setting. + for (let pref in SETTINGS[setting]) { + let msg = `${pref} set correctly for ${setting}`; + if (pref === "network.http.speculative-parallel-limit") { + equal(Preferences.get(pref), 0, msg); + } else { + equal(Preferences.get(pref), !SETTINGS[setting][pref], msg); + } + } + + // Change the value with a newer extension. + testExtensions[1].sendMessage("set", { value: true }, setting); + data = await testExtensions[1].awaitMessage("afterSet"); + ok( + data.value, + "get returns expected value after setting via newer extension." + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Verify the prefs have been set to match the "true" setting. + for (let pref in SETTINGS[setting]) { + let msg = `${pref} set correctly for ${setting}`; + if (pref === "network.http.speculative-parallel-limit") { + equal( + Preferences.get(pref), + ExtensionPreferencesManager.getDefaultValue(pref), + msg + ); + } else { + equal(Preferences.get(pref), SETTINGS[setting][pref], msg); + } + } + + // Change the value with an older extension. + testExtensions[0].sendMessage("set", { value: false }, setting); + data = await testExtensions[0].awaitMessage("afterSet"); + ok(data.value, "Newer extension remains in control."); + equal( + data.levelOfControl, + "controlled_by_other_extensions", + "get returns expected levelOfControl when controlled by other." + ); + + // Clear the value of the newer extension. + testExtensions[1].sendMessage("clear", {}, setting); + data = await testExtensions[1].awaitMessage("afterClear"); + ok(!data.value, "Older extension gains control."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + testExtensions[0].sendMessage("get", {}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(!data.value, "Current, older extension has control."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + // Set the value again with the newer extension. + testExtensions[1].sendMessage("set", { value: true }, setting); + data = await testExtensions[1].awaitMessage("afterSet"); + ok( + data.value, + "get returns expected value after setting via newer extension." + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Unload the newer extension. Expect the older extension to regain control. + await testExtensions[1].unload(); + testExtensions[0].sendMessage("get", {}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(!data.value, "Older extension regained control."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "Expected levelOfControl returned after unloading." + ); + + // Reload the extension for the next iteration of the loop. + testExtensions[1] = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }); + await testExtensions[1].startup(); + + // Clear the value of the older extension. + testExtensions[0].sendMessage("clear", {}, setting); + data = await testExtensions[0].awaitMessage("afterClear"); + ok(data.value, "Setting returns to original value when all are cleared."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + // Verify that our initial values were restored. + for (let pref in SETTINGS[setting]) { + equal( + Preferences.get(pref), + SETTINGS[setting][pref], + `${pref} was reset to its initial value.` + ); + } + } + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_privacy_other_prefs() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.tls.version.min"); + Services.prefs.clearUserPref("security.tls.version.max"); + }); + + const cookieSvc = Ci.nsICookieService; + + // Create an object to hold the values to which we will initialize the prefs. + const SETTINGS = { + "network.webRTCIPHandlingPolicy": { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + }, + "network.tlsVersionRestriction": { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 4, + }, + "network.peerConnectionEnabled": { + "media.peerconnection.enabled": true, + }, + "services.passwordSavingEnabled": { + "signon.rememberSignons": true, + }, + "websites.referrersEnabled": { + "network.http.sendRefererHeader": 2, + }, + "websites.resistFingerprinting": { + "privacy.resistFingerprinting": true, + }, + "websites.firstPartyIsolate": { + "privacy.firstparty.isolate": false, + }, + "websites.cookieConfig": { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + }; + + let defaultPrefs = new Preferences({ defaultBranch: true }); + let defaultCookieBehavior = defaultPrefs.get("network.cookie.cookieBehavior"); + let defaultBehavior; + switch (defaultCookieBehavior) { + case cookieSvc.BEHAVIOR_ACCEPT: + defaultBehavior = "allow_all"; + break; + case cookieSvc.BEHAVIOR_REJECT_FOREIGN: + defaultBehavior = "reject_third_party"; + break; + case cookieSvc.BEHAVIOR_REJECT: + defaultBehavior = "reject_all"; + break; + case cookieSvc.BEHAVIOR_LIMIT_FOREIGN: + defaultBehavior = "allow_visited"; + break; + case cookieSvc.BEHAVIOR_REJECT_TRACKER: + defaultBehavior = "reject_trackers"; + break; + case cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN: + defaultBehavior = "reject_trackers_and_partition_foreign"; + break; + default: + ok( + false, + `Unexpected cookie behavior encountered: ${defaultCookieBehavior}` + ); + break; + } + + async function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let data = args[0]; + // The second argument is the end of the api name, + // e.g., "network.webRTCIPHandlingPolicy". + let apiObj = args[1].split(".").reduce((o, i) => o[i], browser.privacy); + let settingData; + switch (msg) { + case "set": + try { + await apiObj.set(data); + } catch (e) { + browser.test.sendMessage("settingThrowsException", { + message: e.message, + }); + break; + } + settingData = await apiObj.get({}); + browser.test.sendMessage("settingData", settingData); + break; + case "get": + settingData = await apiObj.get({}); + browser.test.sendMessage("gettingData", settingData); + break; + } + }); + } + + // Set prefs to our initial values. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.set(pref, SETTINGS[setting][pref]); + } + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.reset(pref); + } + } + }); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + async function testSetting(setting, value, expected, expectedValue = value) { + extension.sendMessage("set", { value: value }, setting); + let data = await extension.awaitMessage("settingData"); + deepEqual( + data.value, + expectedValue, + `Got expected result on setting ${setting} to ${uneval(value)}` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${expected[pref]}` + ); + } + } + + async function testSettingException(setting, value, expected) { + extension.sendMessage("set", { value: value }, setting); + let data = await extension.awaitMessage("settingThrowsException"); + equal(data.message, expected); + } + + async function testGetting(getting, expected, expectedValue) { + extension.sendMessage("get", null, getting); + let data = await extension.awaitMessage("gettingData"); + deepEqual( + data.value, + expectedValue, + `Got expected result on getting ${getting}` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} get correctly for ${expected[pref]}` + ); + } + } + + await testSetting( + "network.webRTCIPHandlingPolicy", + "default_public_and_private_interfaces", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting( + "network.webRTCIPHandlingPolicy", + "default_public_interface_only", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": true, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting( + "network.webRTCIPHandlingPolicy", + "disable_non_proxied_udp", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": true, + "media.peerconnection.ice.proxy_only_if_behind_proxy": true, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting("network.webRTCIPHandlingPolicy", "proxy_only", { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": true, + }); + await testSetting("network.webRTCIPHandlingPolicy", "default", { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + }); + + await testSetting("network.peerConnectionEnabled", false, { + "media.peerconnection.enabled": false, + }); + await testSetting("network.peerConnectionEnabled", true, { + "media.peerconnection.enabled": true, + }); + + await testSetting("websites.referrersEnabled", false, { + "network.http.sendRefererHeader": 0, + }); + await testSetting("websites.referrersEnabled", true, { + "network.http.sendRefererHeader": 2, + }); + + await testSetting("websites.resistFingerprinting", false, { + "privacy.resistFingerprinting": false, + }); + await testSetting("websites.resistFingerprinting", true, { + "privacy.resistFingerprinting": true, + }); + + await testSetting("websites.trackingProtectionMode", "always", { + "privacy.trackingprotection.enabled": true, + "privacy.trackingprotection.pbmode.enabled": true, + }); + await testSetting("websites.trackingProtectionMode", "never", { + "privacy.trackingprotection.enabled": false, + "privacy.trackingprotection.pbmode.enabled": false, + }); + await testSetting("websites.trackingProtectionMode", "private_browsing", { + "privacy.trackingprotection.enabled": false, + "privacy.trackingprotection.pbmode.enabled": true, + }); + + await testSetting("services.passwordSavingEnabled", false, { + "signon.rememberSignons": false, + }); + await testSetting("services.passwordSavingEnabled", true, { + "signon.rememberSignons": true, + }); + + await testSetting( + "websites.cookieConfig", + { behavior: "reject_third_party", nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION, + } + ); + // A missing nonPersistentCookies property should default to false. + await testSetting( + "websites.cookieConfig", + { behavior: "reject_third_party" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "reject_third_party", nonPersistentCookies: false } + ); + // A missing behavior property should reset the pref. + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION, + }, + { behavior: defaultBehavior, nonPersistentCookies: true } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_all" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "reject_all", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "allow_visited" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_LIMIT_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "allow_visited", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "allow_all" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "allow_all", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION, + }, + { behavior: defaultBehavior, nonPersistentCookies: true } + ); + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: false }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: defaultBehavior, nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "reject_trackers", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + // 1. Can't enable FPI when cookie behavior is "reject_trackers_and_partition_foreign" + await testSettingException( + "websites.firstPartyIsolate", + true, + "Can't enable firstPartyIsolate when cookieBehavior is 'reject_trackers_and_partition_foreign'" + ); + + // 2. Change cookieConfig to reject_trackers should work normally. + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "reject_trackers", nonPersistentCookies: false } + ); + + // 3. Enable FPI + await testSetting("websites.firstPartyIsolate", true, { + "privacy.firstparty.isolate": true, + }); + + // 4. When FPI is enabled, change setting to "reject_trackers_and_partition_foreign" is invalid + await testSettingException( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + "Invalid cookieConfig 'reject_trackers_and_partition_foreign' when firstPartyIsolate is enabled" + ); + + // 5. Set conflict settings manually and check prefs. + Preferences.set("network.cookie.cookieBehavior", 5); + await testGetting( + "websites.firstPartyIsolate", + { "privacy.firstparty.isolate": true }, + true + ); + await testGetting( + "websites.cookieConfig", + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + // 6. It is okay to set current saved value. + await testSetting("websites.firstPartyIsolate", true, { + "privacy.firstparty.isolate": true, + }); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + await testSetting("websites.firstPartyIsolate", false, { + "privacy.firstparty.isolate": false, + }); + + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + maximum: "TLSv1.3", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 4, + } + ); + + // Single values + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + } + ); + + // Single values + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + } + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "invalid", + maximum: "invalid", + }, + "Setting TLS version invalid is not allowed for security reasons." + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "invalid2", + }, + "Setting TLS version invalid2 is not allowed for security reasons." + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "invalid3", + }, + "Setting TLS version invalid3 is not allowed for security reasons." + ); + + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.2", + maximum: "TLSv1.3", + } + ); + + await testSetting( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.2", + }, + { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 3, + }, + { + minimum: tlsMinVer, + maximum: "TLSv1.2", + } + ); + + // Not supported version. + if (tlsMinPref === 3) { + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1", + }, + "Setting TLS version TLSv1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.1", + }, + "Setting TLS version TLSv1.1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1", + }, + "Setting TLS version TLSv1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.1", + }, + "Setting TLS version TLSv1.1 is not allowed for security reasons." + ); + } + + // Min vs Max + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + maximum: "TLSv1.2", + }, + "Setting TLS min version grater than the max version is not allowed." + ); + + // Min vs Max (with default max) + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + maximum: "TLSv1.2", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 3, + } + ); + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + "Setting TLS min version grater than the max version is not allowed." + ); + + // Max vs Min + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + } + ); + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.2", + }, + "Setting TLS max version lower than the min version is not allowed." + ); + + // Empty value. + await testSetting( + "network.tlsVersionRestriction", + {}, + { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 4, + }, + { + minimum: tlsMinVer, + maximum: "TLSv1.3", + } + ); + + const HTTPS_ONLY_PREF_NAME = "dom.security.https_only_mode"; + const HTTPS_ONLY_PBM_PREF_NAME = "dom.security.https_only_mode_pbm"; + + Preferences.set(HTTPS_ONLY_PREF_NAME, false); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false); + await testGetting("network.httpsOnlyMode", {}, "never"); + + Preferences.set(HTTPS_ONLY_PREF_NAME, true); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false); + await testGetting("network.httpsOnlyMode", {}, "always"); + + Preferences.set(HTTPS_ONLY_PREF_NAME, false); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true); + await testGetting("network.httpsOnlyMode", {}, "private_browsing"); + + // Please note that if https_only_mode = true, then + // https_only_mode_pbm has no effect. + Preferences.set(HTTPS_ONLY_PREF_NAME, true); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true); + await testGetting("network.httpsOnlyMode", {}, "always"); + + // trying to "set" should have no effect when readonly! + extension.sendMessage("set", { value: "never" }, "network.httpsOnlyMode"); + let readOnlyData = await extension.awaitMessage("settingData"); + equal(readOnlyData.value, "always"); + + equal(Preferences.get(HTTPS_ONLY_PREF_NAME), true); + equal(Preferences.get(HTTPS_ONLY_PBM_PREF_NAME), true); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_exceptions() { + async function background() { + await browser.test.assertRejects( + browser.privacy.network.networkPredictionEnabled.set({ + value: true, + scope: "regular_only", + }), + "Firefox does not support the regular_only settings scope.", + "Expected rejection calling set with invalid scope." + ); + + await browser.test.assertRejects( + browser.privacy.network.networkPredictionEnabled.clear({ + scope: "incognito_persistent", + }), + "Firefox does not support the incognito_persistent settings scope.", + "Expected rejection calling clear with invalid scope." + ); + + browser.test.notifyPass("exceptionTests"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("exceptionTests"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js new file mode 100644 index 0000000000..ff0d4d9d48 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js @@ -0,0 +1,201 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyGetter(this, "Management", () => { + // eslint-disable-next-line no-shadow + const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null + ); + return Management; +}); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +function awaitEvent(eventName) { + return new Promise(resolve => { + let listener = (_eventName, ...args) => { + if (_eventName === eventName) { + Management.off(eventName, listener); + resolve(...args); + } + }; + + Management.on(eventName, listener); + }); +} + +function awaitPrefChange(prefName) { + return new Promise(resolve => { + let listener = args => { + Preferences.ignore(prefName, listener); + resolve(); + }; + + Preferences.observe(prefName, listener); + }); +} + +add_task(async function test_disable() { + const OLD_ID = "old_id@tests.mozilla.org"; + const NEW_ID = "new_id@tests.mozilla.org"; + + const PREF_TO_WATCH = "network.http.speculative-parallel-limit"; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + function checkPrefs(expected) { + for (let pref in PREFS) { + let msg = `${pref} set correctly.`; + let expectedValue = expected ? PREFS[pref] : !PREFS[pref]; + if (pref === "network.http.speculative-parallel-limit") { + expectedValue = expected + ? ExtensionPreferencesManager.getDefaultValue(pref) + : 0; + } + equal(Preferences.get(pref), expectedValue, msg); + } + } + + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + await browser.privacy.network.networkPredictionEnabled.set(data); + let settingData = await browser.privacy.network.networkPredictionEnabled.get( + {} + ); + browser.test.sendMessage("privacyData", settingData); + }); + } + + await promiseStartupManager(); + + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { + gecko: { + id: OLD_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { + gecko: { + id: NEW_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Set the value to true for the older extension. + testExtensions[0].sendMessage("set", { value: true }); + let data = await testExtensions[0].awaitMessage("privacyData"); + ok(data.value, "Value set to true for the older extension."); + + // Set the value to false for the newest extension. + testExtensions[1].sendMessage("set", { value: false }); + data = await testExtensions[1].awaitMessage("privacyData"); + ok(!data.value, "Value set to false for the newest extension."); + + // Verify the prefs have been set to match the "false" setting. + checkPrefs(false); + + // Disable the newest extension. + let disabledPromise = awaitPrefChange(PREF_TO_WATCH); + let newAddon = await AddonManager.getAddonByID(NEW_ID); + await newAddon.disable(); + await disabledPromise; + + // Verify the prefs have been set to match the "true" setting. + checkPrefs(true); + + // Disable the older extension. + disabledPromise = awaitPrefChange(PREF_TO_WATCH); + let oldAddon = await AddonManager.getAddonByID(OLD_ID); + await oldAddon.disable(); + await disabledPromise; + + // Verify the prefs have reverted back to their initial values. + for (let pref in PREFS) { + equal(Preferences.get(pref), PREFS[pref], `${pref} reset correctly.`); + } + + // Re-enable the newest extension. + let enabledPromise = awaitEvent("ready"); + await newAddon.enable(); + await enabledPromise; + + // Verify the prefs have been set to match the "false" setting. + checkPrefs(false); + + // Re-enable the older extension. + enabledPromise = awaitEvent("ready"); + await oldAddon.enable(); + await enabledPromise; + + // Verify the prefs have remained set to match the "false" setting. + checkPrefs(false); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js new file mode 100644 index 0000000000..8b9ae6be9c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js @@ -0,0 +1,167 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_privacy_update() { + // Create a object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }; + + const EXTENSION_ID = "test_privacy_addon_update@tests.mozilla.org"; + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + let settingData; + switch (msg) { + case "get": + settingData = await browser.privacy.network.networkPredictionEnabled.get( + {} + ); + browser.test.sendMessage("privacyData", settingData); + break; + + case "set": + await browser.privacy.network.networkPredictionEnabled.set(data); + settingData = await browser.privacy.network.networkPredictionEnabled.get( + {} + ); + browser.test.sendMessage("privacyData", settingData); + break; + } + }); + } + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_privacy-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + permissions: ["privacy"], + }, + background, + }); + + testServer.registerFile("/addons/test_privacy-2.0.xpi", webExtensionFile); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + update_url: `http://localhost:${port}/test_update.json`, + }, + }, + permissions: ["privacy"], + }, + background, + }); + + await extension.startup(); + + // Change the value to false. + extension.sendMessage("set", { value: false }); + let data = await extension.awaitMessage("privacyData"); + ok(!data.value, "get returns expected value after setting."); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + + let update = await promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + + await promiseCompleteAllInstalls([install]); + + await extension.awaitStartup(); + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + + extension.sendMessage("get"); + data = await extension.awaitMessage("privacyData"); + ok(!data.value, "get returns expected value after updating."); + + // Verify the prefs are still set to match the "false" setting. + for (let pref in PREFS) { + let msg = `${pref} set correctly.`; + let expectedValue = + pref === "network.http.speculative-parallel-limit" ? 0 : !PREFS[pref]; + equal(Preferences.get(pref), expectedValue, msg); + } + + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js new file mode 100644 index 0000000000..27f537b73b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js @@ -0,0 +1,116 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "authManager", + "@mozilla.org/network/http-auth-manager;1", + "nsIHttpAuthManager" +); + +const proxy = createHttpServer(); +const proxyToken = "this_is_my_pass"; + +// accept proxy connections for mozilla.org +proxy.identity.add("http", "mozilla.org", 80); + +proxy.registerPathHandler("/", (request, response) => { + if (request.hasHeader("Proxy-Authorization")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + response.write(request.getHeader("Proxy-Authorization")); + } else { + response.setStatusLine( + request.httpVersion, + 407, + "Proxy authentication required" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", "UnknownMeantToFail", false); + response.write("auth required"); + } +}); + +function getExtension(background) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${proxy.identity.primaryPort}, "${proxyToken}")`, + }); +} + +add_task(async function test_webRequest_auth_proxy() { + function background(port, proxyToken) { + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set" + ); + browser.test.assertEq( + proxyToken, + details.proxyInfo.proxyAuthorizationHeader, + "proxy authorization header" + ); + browser.test.assertEq( + proxyToken, + details.proxyInfo.connectionIsolationKey, + "proxy connection isolation" + ); + + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + // Using proxyAuthorizationHeader should prevent an auth request coming to us in the extension. + browser.test.fail("onAuthRequired"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [ + { + host: "localhost", + port, + type: "http", + proxyAuthorizationHeader: proxyToken, + connectionIsolationKey: proxyToken, + }, + ]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + } + + let extension = getExtension(background); + + await extension.startup(); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://mozilla.org/` + ); + + await extension.awaitFinish("requestCompleted"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js new file mode 100644 index 0000000000..953bf4bea5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js @@ -0,0 +1,633 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_browser_settings() { + const proxySvc = Ci.nsIProtocolProxyService; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "network.proxy.http": "", + "network.proxy.http_port": 0, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "", + "network.proxy.ftp_port": 0, + "network.proxy.ssl": "", + "network.proxy.ssl_port": 0, + "network.proxy.socks": "", + "network.proxy.socks_port": 0, + "network.proxy.socks_version": 5, + "network.proxy.socks_remote_dns": false, + "network.proxy.no_proxies_on": "", + "network.proxy.autoconfig_url": "", + "signon.autologin.proxy": false, + }; + + async function background() { + browser.test.onMessage.addListener(async (msg, value) => { + let apiObj = browser.proxy.settings; + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + browser.test.sendMessage("settingData", await apiObj.get({})); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + browser.test.sendMessage("no-op set"); + } + }); + } + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["proxy"], + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }); + + await extension.startup(); + + async function testSetting(value, expected, expectedValue = value) { + extension.sendMessage("set", value); + let data = await extension.awaitMessage("settingData"); + deepEqual(data.value, expectedValue, `The setting has the expected value.`); + equal( + data.levelOfControl, + "controlled_by_this_extension", + `The setting has the expected levelOfControl.` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + async function testProxy(config, expectedPrefs, expectedConfig = config) { + // proxy.settings is not supported on Android. + if (AppConstants.platform === "android") { + return Promise.resolve(); + } + + let proxyConfig = { + proxyType: "none", + autoConfigUrl: "", + autoLogin: false, + proxyDNS: false, + httpProxyAll: false, + socksVersion: 5, + passthrough: "", + http: "", + ftp: "", + ssl: "", + socks: "", + respectBeConservative: true, + }; + + expectedConfig.proxyType = expectedConfig.proxyType || "system"; + + return testSetting( + config, + expectedPrefs, + Object.assign(proxyConfig, expectedConfig) + ); + } + + await testProxy( + { proxyType: "none" }, + { "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT } + ); + + await testProxy( + { + proxyType: "autoDetect", + autoLogin: true, + proxyDNS: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_WPAD, + "signon.autologin.proxy": true, + "network.proxy.socks_remote_dns": true, + } + ); + + await testProxy( + { + proxyType: "system", + autoLogin: false, + proxyDNS: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "signon.autologin.proxy": false, + "network.proxy.socks_remote_dns": false, + } + ); + + // Verify that proxyType is optional and it defaults to "system". + await testProxy( + { + autoLogin: false, + proxyDNS: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "signon.autologin.proxy": false, + "network.proxy.socks_remote_dns": false, + "network.http.proxy.respect-be-conservative": true, + } + ); + + await testProxy( + { + proxyType: "autoConfig", + autoConfigUrl: "http://mozilla.org", + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_PAC, + "network.proxy.autoconfig_url": "http://mozilla.org", + "network.http.proxy.respect-be-conservative": true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org", + autoConfigUrl: "", + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.autoconfig_url": "", + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + autoConfigUrl: "", + } + ); + + // When using proxyAll, we expect all proxies to be set to + // be the same as http. + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:8080", + ftp: "http://www.mozilla.org:1234", + httpProxyAll: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 8080, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 8080, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 8080, + "network.proxy.share_proxy_settings": true, + }, + { + proxyType: "manual", + http: "www.mozilla.org:8080", + ftp: "www.mozilla.org:8080", + ssl: "www.mozilla.org:8080", + socks: "", + httpProxyAll: true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ftp: "www.mozilla.org:8081", + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 8080, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 8081, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 8082, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 8083, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org", + ftp: "ftp://www.mozilla.org", + ssl: "https://www.mozilla.org", + socks: "mozilla.org", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 21, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 443, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 1080, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": false, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ftp: "www.mozilla.org:21", + ssl: "www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:80", + ftp: "ftp://www.mozilla.org:21", + ssl: "https://www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 21, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 443, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 1080, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": true, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ftp: "www.mozilla.org:21", + ssl: "www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:80", + ftp: "ftp://www.mozilla.org:80", + ssl: "https://www.mozilla.org:80", + socks: "mozilla.org:80", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 80, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 80, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 80, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": false, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ftp: "www.mozilla.org:80", + ssl: "www.mozilla.org:80", + socks: "mozilla.org:80", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + } + ); + + // Test resetting values. + await testProxy( + { + proxyType: "none", + http: "", + ftp: "", + ssl: "", + socks: "", + socksVersion: 5, + passthrough: "", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT, + "network.proxy.http": "", + "network.proxy.http_port": 0, + "network.proxy.ftp": "", + "network.proxy.ftp_port": 0, + "network.proxy.ssl": "", + "network.proxy.ssl_port": 0, + "network.proxy.socks": "", + "network.proxy.socks_port": 0, + "network.proxy.socks_version": 5, + "network.proxy.no_proxies_on": "", + "network.http.proxy.respect-be-conservative": true, + } + ); + + await extension.unload(); +}); + +add_task(async function test_bad_value_proxy_config() { + let background = + AppConstants.platform === "android" + ? async () => { + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "none", + }, + }), + /proxy.settings is not supported on android/, + "proxy.settings.set rejects on Android." + ); + + await browser.test.assertRejects( + browser.proxy.settings.get({}), + /proxy.settings is not supported on android/, + "proxy.settings.get rejects on Android." + ); + + await browser.test.assertRejects( + browser.proxy.settings.clear({}), + /proxy.settings is not supported on android/, + "proxy.settings.clear rejects on Android." + ); + + browser.test.sendMessage("done"); + } + : async () => { + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "abc", + }, + }), + /abc is not a valid value for proxyType/, + "proxy.settings.set rejects with an invalid proxyType value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "autoConfig", + }, + }), + /undefined is not a valid value for autoConfigUrl/, + "proxy.settings.set for type autoConfig rejects with an empty autoConfigUrl value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "autoConfig", + autoConfigUrl: "abc", + }, + }), + /abc is not a valid value for autoConfigUrl/, + "proxy.settings.set rejects with an invalid autoConfigUrl value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "manual", + socksVersion: "abc", + }, + }), + /abc is not a valid value for socksVersion/, + "proxy.settings.set rejects with an invalid socksVersion value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "manual", + socksVersion: 3, + }, + }), + /3 is not a valid value for socksVersion/, + "proxy.settings.set rejects with an invalid socksVersion value." + ); + + browser.test.sendMessage("done"); + }; + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["proxy"], + }, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Verify proxy prefs are unset on permission removal. +add_task(async function test_proxy_settings_permissions() { + async function background() { + const permObj = { permissions: ["proxy"] }; + browser.test.onMessage.addListener(async (msg, value) => { + if (msg === "request") { + browser.test.log("requesting proxy permission"); + await browser.permissions.request(permObj); + browser.test.log("setting proxy values"); + await browser.proxy.settings.set({ value }); + browser.test.sendMessage("set"); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + browser.test.sendMessage("removed"); + } + }); + } + + let prefNames = [ + "network.proxy.type", + "network.proxy.http", + "network.proxy.http_port", + "network.proxy.ftp", + "network.proxy.ftp_port", + "network.proxy.ssl", + "network.proxy.ssl_port", + "network.proxy.socks", + "network.proxy.socks_port", + "network.proxy.socks_version", + "network.proxy.no_proxies_on", + ]; + + function checkSettings(msg, expectUserValue = false) { + info(msg); + for (let pref of prefNames) { + equal( + expectUserValue, + Services.prefs.prefHasUserValue(pref), + `${pref} set as expected ${Preferences.get(pref)}` + ); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["proxy"], + }, + incognitoOverride: "spanning", + useAddonManager: "permanent", + }); + await extension.startup(); + checkSettings("setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ftp: "www.mozilla.org:8081", + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + }); + await extension.awaitMessage("set"); + checkSettings("setting was set after request", true); + + extension.sendMessage("remove"); + await extension.awaitMessage("removed"); + checkSettings("setting is reset after remove"); + + // Set again to test after restart + extension.sendMessage("request", { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ftp: "www.mozilla.org:8081", + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + }); + await extension.awaitMessage("set"); + checkSettings("setting was set after request", true); + }); + + // force the permissions store to be re-read on startup + await ExtensionPermissions._uninit(); + resetHandlingUserInput(); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("removed"); + checkSettings("setting is reset after remove"); + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js new file mode 100644 index 0000000000..db041d20d0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js @@ -0,0 +1,302 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "authManager", + "@mozilla.org/network/http-auth-manager;1", + "nsIHttpAuthManager" +); + +const proxy = createHttpServer(); + +// accept proxy connections for mozilla.org +proxy.identity.add("http", "mozilla.org", 80); +proxy.identity.add("https", "407.example.com", 443); + +proxy.registerPathHandler("CONNECT", (request, response) => { + Assert.equal(request.method, "CONNECT"); + switch (request.host) { + case "407.example.com": + response.setStatusLine(request.httpVersion, 407, "Authenticate"); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false); + response.write("auth required"); + break; + default: + response.setStatusLine(request.httpVersion, 500, "I am dumb"); + } +}); + +proxy.registerPathHandler("/", (request, response) => { + if (request.hasHeader("Proxy-Authorization")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + response.write("ok, got proxy auth"); + } else { + response.setStatusLine( + request.httpVersion, + 407, + "Proxy authentication required" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false); + response.write("auth required"); + } +}); + +function getExtension(background) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${proxy.identity.primaryPort})`, + }); +} + +add_task(async function test_webRequest_auth_proxy() { + async function background(port) { + let expecting = [ + "onBeforeSendHeaders", + "onSendHeaders", + "onAuthRequired", + "onBeforeSendHeaders", + "onSendHeaders", + "onCompleted", + ]; + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onBeforeSendHeaders", + expecting.shift(), + "got expected event" + ); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set" + ); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onSendHeaders.addListener( + details => { + browser.test.log(`onSendHeaders ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onSendHeaders", + expecting.shift(), + "got expected event" + ); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log(`onAuthRequired ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onAuthRequired", + expecting.shift(), + "got expected event" + ); + browser.test.assertTrue(details.isProxy, "proxied request"); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "localhost", + details.challenger.host, + "proxy host" + ); + browser.test.assertEq(port, details.challenger.port, "proxy port"); + return { authCredentials: { username: "puser", password: "ppass" } }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onCompleted", + expecting.shift(), + "got expected event" + ); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set by onAuthRequired" + ); + browser.test.assertEq( + undefined, + details.proxyInfo.password, + "no proxy password" + ); + browser.test.assertEq(expecting.length, 0, "got all expected events"); + browser.test.sendMessage("done"); + }, + { urls: ["<all_urls>"] } + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [{ host: "localhost", port, type: "http" }]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://mozilla.org/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); + +add_task(async function test_webRequest_auth_proxy_https() { + async function background(port) { + let authReceived = false; + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + if (authReceived) { + browser.test.sendMessage("done"); + return { cancel: true }; + } + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + authReceived = true; + return { authCredentials: { username: "puser", password: "ppass" } }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [{ host: "localhost", port, type: "http" }]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `https://407.example.com/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); + +add_task(async function test_webRequest_auth_proxy_system() { + async function background(port) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail("onBeforeRequest"); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.sendMessage("onAuthRequired"); + // cancel is silently ignored, if it were not (e.g someone messes up in + // WebRequest.jsm and allows cancel) this test would fail. + return { + cancel: true, + authCredentials: { username: "puser", password: "ppass" }, + }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return { host: "localhost", port, type: "http" }; + }, + { urls: ["<all_urls>"] } + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + function fetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.mozBackgroundRequest = true; + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = () => { + reject(xhr.status); + }; + xhr.send(); + }); + } + + await Promise.all([ + handlingExt.awaitMessage("onAuthRequired"), + fetch("http://mozilla.org"), + ]); + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js new file mode 100644 index 0000000000..281804dccb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js @@ -0,0 +1,107 @@ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "HttpServer", + "resource://testing-common/httpd.js" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// We cannot use createHttpServer because it also messes with proxies. We want +// httpChannel to pick up the prefs we set and use those to proxy to our server. +// If this were to fail, we would get an error about making a request out to +// the network. +const proxy = new HttpServer(); +proxy.start(-1); +proxy.registerPathHandler("/fubar", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); +registerCleanupFunction(() => { + return new Promise(resolve => { + proxy.stop(resolve); + }); +}); + +add_task(async function test_proxy_settings() { + async function background(host, port) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + host, + details.proxyInfo.host, + "proxy host matched" + ); + browser.test.assertEq( + port, + details.proxyInfo.port, + "proxy port matched" + ); + }, + { urls: ["http://example.com/*"] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.notifyPass("proxytest"); + }, + { urls: ["http://example.com/*"] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.notifyFail("proxytest"); + }, + { urls: ["http://example.com/*"] } + ); + + // Wait for the settings before testing a request. + await browser.proxy.settings.set({ + value: { + proxyType: "manual", + http: `${host}:${port}`, + }, + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "proxy.settings@mochi.test" } }, + permissions: ["proxy", "webRequest", "<all_urls>"], + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + background: `(${background})("${proxy.identity.primaryHost}", ${proxy.identity.primaryPort})`, + }); + + await promiseStartupManager(); + await extension.startup(); + await extension.awaitMessage("ready"); + equal( + Services.prefs.getStringPref("network.proxy.http"), + proxy.identity.primaryHost, + "proxy address is set" + ); + equal( + Services.prefs.getIntPref("network.proxy.http_port"), + proxy.identity.primaryPort, + "proxy port is set" + ); + let ok = extension.awaitFinish("proxytest"); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/fubar" + ); + await ok; + + await contentPage.close(); + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js new file mode 100644 index 0000000000..62436737f1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js @@ -0,0 +1,557 @@ +"use strict"; + +/* globals TCPServerSocket */ + +const CC = Components.Constructor; + +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +const currentThread = Cc["@mozilla.org/thread-manager;1"].getService() + .currentThread; + +// Most of the socks logic here is copied and upgraded to support authentication +// for socks5. The original test is from netwerk/test/unit/test_socks.js + +// Socks 4 support was left in place for future tests. + +const STATE_WAIT_GREETING = 1; +const STATE_WAIT_SOCKS4_REQUEST = 2; +const STATE_WAIT_SOCKS4_USERNAME = 3; +const STATE_WAIT_SOCKS4_HOSTNAME = 4; +const STATE_WAIT_SOCKS5_GREETING = 5; +const STATE_WAIT_SOCKS5_REQUEST = 6; +const STATE_WAIT_SOCKS5_AUTH = 7; +const STATE_WAIT_INPUT = 8; +const STATE_FINISHED = 9; + +/** + * A basic socks proxy setup that handles a single http response page. This + * is used for testing socks auth with webrequest. We don't bother making + * sure we buffer ondata, etc., we'll never get anything but tiny chunks here. + */ +class SocksClient { + constructor(server, socket) { + this.server = server; + this.type = ""; + this.username = ""; + this.dest_name = ""; + this.dest_addr = []; + this.dest_port = []; + + this.inbuf = []; + this.state = STATE_WAIT_GREETING; + this.socket = socket; + + socket.onclose = event => { + this.server.requestCompleted(this); + }; + socket.ondata = event => { + let len = event.data.byteLength; + + if (len == 0 && this.state == STATE_FINISHED) { + this.close(); + this.server.requestCompleted(this); + return; + } + + this.inbuf = new Uint8Array(event.data); + Promise.resolve().then(() => { + this.callState(); + }); + }; + } + + callState() { + switch (this.state) { + case STATE_WAIT_GREETING: + this.checkSocksGreeting(); + break; + case STATE_WAIT_SOCKS4_REQUEST: + this.checkSocks4Request(); + break; + case STATE_WAIT_SOCKS4_USERNAME: + this.checkSocks4Username(); + break; + case STATE_WAIT_SOCKS4_HOSTNAME: + this.checkSocks4Hostname(); + break; + case STATE_WAIT_SOCKS5_GREETING: + this.checkSocks5Greeting(); + break; + case STATE_WAIT_SOCKS5_REQUEST: + this.checkSocks5Request(); + break; + case STATE_WAIT_SOCKS5_AUTH: + this.checkSocks5Auth(); + break; + case STATE_WAIT_INPUT: + this.checkRequest(); + break; + default: + do_throw("server: read in invalid state!"); + } + } + + write(buf) { + this.socket.send(new Uint8Array(buf).buffer); + } + + checkSocksGreeting() { + if (!this.inbuf.length) { + return; + } + + if (this.inbuf[0] == 4) { + this.type = "socks4"; + this.state = STATE_WAIT_SOCKS4_REQUEST; + this.checkSocks4Request(); + } else if (this.inbuf[0] == 5) { + this.type = "socks"; + this.state = STATE_WAIT_SOCKS5_GREETING; + this.checkSocks5Greeting(); + } else { + do_throw("Unknown socks protocol!"); + } + } + + checkSocks4Request() { + if (this.inbuf.length < 8) { + return; + } + + this.dest_port = this.inbuf.slice(2, 4); + this.dest_addr = this.inbuf.slice(4, 8); + + this.inbuf = this.inbuf.slice(8); + this.state = STATE_WAIT_SOCKS4_USERNAME; + this.checkSocks4Username(); + } + + readString() { + let i = this.inbuf.indexOf(0); + let str = null; + + if (i >= 0) { + let decoder = new TextDecoder(); + str = decoder.decode(this.inbuf.slice(0, i)); + this.inbuf = this.inbuf.slice(i + 1); + } + + return str; + } + + checkSocks4Username() { + let str = this.readString(); + + if (str == null) { + return; + } + + this.username = str; + if ( + this.dest_addr[0] == 0 && + this.dest_addr[1] == 0 && + this.dest_addr[2] == 0 && + this.dest_addr[3] != 0 + ) { + this.state = STATE_WAIT_SOCKS4_HOSTNAME; + this.checkSocks4Hostname(); + } else { + this.sendSocks4Response(); + } + } + + checkSocks4Hostname() { + let str = this.readString(); + + if (str == null) { + return; + } + + this.dest_name = str; + this.sendSocks4Response(); + } + + sendSocks4Response() { + this.state = STATE_WAIT_INPUT; + this.inbuf = []; + this.write([0, 0x5a, 0, 0, 0, 0, 0, 0]); + } + + /** + * checks authentication information. + * + * buf[0] socks version + * buf[1] number of auth methods supported + * buf[2+nmethods] value for each auth method + * + * Response is + * byte[0] socks version + * byte[1] desired auth method + * + * For whatever reason, Firefox does not present auth method 0x02 however + * responding with that does cause Firefox to send authentication if + * the nsIProxyInfo instance has the data. IUUC Firefox should send + * supported methods, but I'm no socks expert. + */ + checkSocks5Greeting() { + if (this.inbuf.length < 2) { + return; + } + let nmethods = this.inbuf[1]; + if (this.inbuf.length < 2 + nmethods) { + return; + } + + // See comment above, keeping for future update. + // let methods = this.inbuf.slice(2, 2 + nmethods); + + this.inbuf = []; + if (this.server.password || this.server.username) { + this.state = STATE_WAIT_SOCKS5_AUTH; + this.write([5, 2]); + } else { + this.state = STATE_WAIT_SOCKS5_REQUEST; + this.write([5, 0]); + } + } + + checkSocks5Auth() { + equal(this.inbuf[0], 0x01, "subnegotiation version"); + let uname_len = this.inbuf[1]; + let pass_len = this.inbuf[2 + uname_len]; + let unnamebuf = this.inbuf.slice(2, 2 + uname_len); + let pass_start = 2 + uname_len + 1; + let pwordbuf = this.inbuf.slice(pass_start, pass_start + pass_len); + let decoder = new TextDecoder(); + let username = decoder.decode(unnamebuf); + let password = decoder.decode(pwordbuf); + this.inbuf = []; + equal(username, this.server.username, "socks auth username"); + equal(password, this.server.password, "socks auth password"); + if (username == this.server.username && password == this.server.password) { + this.state = STATE_WAIT_SOCKS5_REQUEST; + // x00 is success, any other value closes the connection + this.write([1, 0]); + return; + } + this.state = STATE_FINISHED; + this.write([1, 1]); + } + + checkSocks5Request() { + if (this.inbuf.length < 4) { + return; + } + + let atype = this.inbuf[3]; + let len; + let name = false; + + switch (atype) { + case 0x01: + len = 4; + break; + case 0x03: + len = this.inbuf[4]; + name = true; + break; + case 0x04: + len = 16; + break; + default: + do_throw("Unknown address type " + atype); + } + + if (name) { + if (this.inbuf.length < 4 + len + 1 + 2) { + return; + } + + let buf = this.inbuf.slice(5, 5 + len); + let decoder = new TextDecoder(); + this.dest_name = decoder.decode(buf); + len += 1; + } else { + if (this.inbuf.length < 4 + len + 2) { + return; + } + + this.dest_addr = this.inbuf.slice(4, 4 + len); + } + + len += 4; + this.dest_port = this.inbuf.slice(len, len + 2); + this.inbuf = this.inbuf.slice(len + 2); + this.sendSocks5Response(); + } + + sendSocks5Response() { + let buf; + if (this.dest_addr.length == 16) { + // send a successful response with the address, [::1]:80 + buf = [5, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 80]; + } else { + // send a successful response with the address, 127.0.0.1:80 + buf = [5, 0, 0, 1, 127, 0, 0, 1, 0, 80]; + } + this.state = STATE_WAIT_INPUT; + this.inbuf = []; + this.write(buf); + } + + checkRequest() { + let decoder = new TextDecoder(); + let request = decoder.decode(this.inbuf); + + if (request == "PING!") { + this.state = STATE_FINISHED; + this.socket.send("PONG!"); + } else if (request.startsWith("GET / HTTP/1.1")) { + this.socket.send( + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 2\r\n" + + "Content-Type: text/html\r\n" + + "\r\nOK" + ); + this.state = STATE_FINISHED; + } + } + + close() { + this.socket.close(); + } +} + +class SocksTestServer { + constructor() { + this.client_connections = new Set(); + this.listener = new TCPServerSocket(-1, { binaryType: "arraybuffer" }, -1); + this.listener.onconnect = event => { + let client = new SocksClient(this, event.socket); + this.client_connections.add(client); + }; + } + + requestCompleted(client) { + this.client_connections.delete(client); + } + + close() { + for (let client of this.client_connections) { + client.close(); + } + this.client_connections = new Set(); + if (this.listener) { + this.listener.close(); + this.listener = null; + } + } + + setUserPass(username, password) { + this.username = username; + this.password = password; + } +} + +/** + * Tests the basic socks logic using a simple socket connection and the + * protocol proxy service. It seems TCPSocket has no way to tie proxy + * data to it, so we go old school here. + */ +class SocksTestClient { + constructor(socks, dest, resolve, reject) { + let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService( + Ci.nsIProtocolProxyService + ); + let sts = Cc["@mozilla.org/network/socket-transport-service;1"].getService( + Ci.nsISocketTransportService + ); + + let pi_flags = 0; + if (socks.dns == "remote") { + pi_flags = Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + } + + let pi = pps.newProxyInfoWithAuth( + socks.version, + socks.host, + socks.port, + socks.username, + socks.password, + "", + "", + pi_flags, + -1, + null + ); + + this.trans = sts.createTransport([], dest.host, dest.port, pi); + this.input = this.trans.openInputStream( + Ci.nsITransport.OPEN_BLOCKING, + 0, + 0 + ); + this.output = this.trans.openOutputStream( + Ci.nsITransport.OPEN_BLOCKING, + 0, + 0 + ); + this.outbuf = String(); + this.resolve = resolve; + this.reject = reject; + + this.write("PING!"); + this.input.asyncWait(this, 0, 0, currentThread); + } + + onInputStreamReady(stream) { + let len = 0; + try { + len = stream.available(); + } catch (e) { + // This will happen on auth failure. + this.reject(e); + return; + } + let bin = new BinaryInputStream(stream); + let data = bin.readByteArray(len); + let decoder = new TextDecoder(); + let result = decoder.decode(data); + if (result == "PONG!") { + this.resolve(result); + } else { + this.reject(); + } + } + + write(buf) { + this.outbuf += buf; + this.output.asyncWait(this, 0, 0, currentThread); + } + + onOutputStreamReady(stream) { + let len = stream.write(this.outbuf, this.outbuf.length); + if (len != this.outbuf.length) { + this.outbuf = this.outbuf.substring(len); + stream.asyncWait(this, 0, 0, currentThread); + } else { + this.outbuf = String(); + } + } + + close() { + this.output.close(); + } +} + +const socksServer = new SocksTestServer(); +socksServer.setUserPass("foo", "bar"); +registerCleanupFunction(() => { + socksServer.close(); +}); + +// A simple ping/pong to test the socks server. +add_task(async function test_socks_server() { + let socks = { + version: "socks", + host: "127.0.0.1", + port: socksServer.listener.localPort, + username: "foo", + password: "bar", + dns: false, + }; + let dest = { + host: "localhost", + port: 8888, + }; + + new Promise((resolve, reject) => { + new SocksTestClient(socks, dest, resolve, reject); + }) + .then(result => { + equal("PONG!", result, "socks test ok"); + }) + .catch(result => { + ok(false, `socks test failed ${result}`); + }); +}); + +add_task(async function test_webRequest_socks_proxy() { + async function background(port) { + function checkProxyData(details) { + browser.test.assertEq("127.0.0.1", details.proxyInfo.host, "proxy host"); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("socks", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "foo", + details.proxyInfo.username, + "proxy username not set" + ); + browser.test.assertEq( + undefined, + details.proxyInfo.password, + "no proxy password passed to webrequest" + ); + } + browser.webRequest.onBeforeRequest.addListener( + details => { + checkProxyData(details); + }, + { urls: ["<all_urls>"] } + ); + browser.webRequest.onAuthRequired.addListener( + details => { + // We should never get onAuthRequired for socks proxy + browser.test.fail("onAuthRequired"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + browser.webRequest.onCompleted.addListener( + details => { + checkProxyData(details); + browser.test.sendMessage("done"); + }, + { urls: ["<all_urls>"] } + ); + browser.proxy.onRequest.addListener( + () => { + return [ + { + type: "socks", + host: "127.0.0.1", + port, + username: "foo", + password: "bar", + }, + ]; + }, + { urls: ["<all_urls>"] } + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${socksServer.listener.localPort})`, + }); + + // proxy.register is deprecated - bug 1443259. + ExtensionTestUtils.failOnSchemaWarnings(false); + await handlingExt.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://localhost/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js new file mode 100644 index 0000000000..01f864cb7a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js @@ -0,0 +1,52 @@ +"use strict"; + +const { ExtensionUtils } = ChromeUtils.import( + "resource://gre/modules/ExtensionUtils.jsm" +); + +const proxy = createHttpServer(); + +add_task(async function test_speculative_connect() { + function background() { + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + browser.test.assertEq( + details.type, + "speculative", + "Should have seen a speculative proxy request." + ); + return [{ type: "direct" }]; + }, + { urls: ["<all_urls>"], types: ["speculative"] } + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})()`, + }); + + Services.prefs.setBoolPref("network.http.debug-observations", true); + + await handlingExt.startup(); + + let notificationPromise = ExtensionUtils.promiseObserved( + "speculative-connect-request" + ); + + let uri = Services.io.newURI( + `http://${proxy.identity.primaryHost}:${proxy.identity.primaryPort}` + ); + Services.io.speculativeConnect( + uri, + Services.scriptSecurityManager.getSystemPrincipal(), + null + ); + await notificationPromise; + + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js new file mode 100644 index 0000000000..8d0f98f308 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js @@ -0,0 +1,158 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +let nonProxiedRequests = 0; +const nonProxiedServer = createHttpServer({ hosts: ["example.com"] }); +nonProxiedServer.registerPathHandler("/", (request, response) => { + nonProxiedRequests++; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +// No hosts defined to avoid proxy filter setup. +let proxiedRequests = 0; +const server = createHttpServer(); +server.identity.add("http", "proxied.example.com", 80); +server.registerPathHandler("/", (request, response) => { + proxiedRequests++; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +function promiseExtensionEvent(wrapper, event) { + return new Promise(resolve => { + wrapper.extension.once(event, resolve); + }); +} + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-page-event", "start-background-page"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +// Test that a proxy listener during startup does not immediately +// start the background page, but the event is queued until the background +// page is started. +add_task(async function test_proxy_startup() { + await promiseStartupManager(); + + function background(proxyInfo) { + browser.proxy.onRequest.addListener( + details => { + // ignore speculative requests + if (details.type == "xmlhttprequest") { + browser.test.sendMessage("saw-request"); + } + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + } + + let proxyInfo = { + host: server.identity.primaryHost, + port: server.identity.primaryPort, + type: "http", + }; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["proxy", "http://proxied.example.com/*"], + }, + background: `(${background})(${JSON.stringify(proxyInfo)})`, + }); + + await extension.startup(); + + // Initial requests to test the proxy and non-proxied servers. + await Promise.all([ + extension.awaitMessage("saw-request"), + ExtensionTestUtils.fetch("http://proxied.example.com/?a=0"), + ]); + equal(1, proxiedRequests, "proxied request ok"); + equal(0, nonProxiedRequests, "non proxied request ok"); + + await ExtensionTestUtils.fetch("http://example.com/?a=0"); + equal(1, proxiedRequests, "proxied request ok"); + equal(1, nonProxiedRequests, "non proxied request ok"); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let events = trackEvents(extension); + + // Initiate a non-proxied request to make sure the startup listeners are using + // the extensions filters/etc. + await ExtensionTestUtils.fetch("http://example.com/?a=1"); + equal(1, proxiedRequests, "proxied request ok"); + equal(2, nonProxiedRequests, "non proxied request ok"); + + equal( + events.get("background-page-event"), + false, + "Should not have gotten a background page event" + ); + + // Make a request that the extension will proxy once it is started. + let request = Promise.all([ + extension.awaitMessage("saw-request"), + ExtensionTestUtils.fetch("http://proxied.example.com/?a=1"), + ]); + + await promiseExtensionEvent(extension, "background-page-event"); + equal( + events.get("background-page-event"), + true, + "Should have gotten a background page event" + ); + + // Test the background page startup. + equal( + events.get("start-background-page"), + false, + "Should have gotten a background page event" + ); + + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await new Promise(executeSoon); + + equal( + events.get("start-background-page"), + true, + "Should have gotten a background page event" + ); + + // Verify our proxied request finishes properly and that the + // request was not handled via our non-proxied server. + await request; + equal(2, proxiedRequests, "proxied request ok"); + equal(2, nonProxiedRequests, "non proxied requests ok"); + + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js new file mode 100644 index 0000000000..4c8175e0c0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js @@ -0,0 +1,567 @@ +"use strict"; + +// Tests whether we can redirect to a moz-extension: url. +ChromeUtils.defineModuleGetter( + this, + "TestUtils", + "resource://testing-common/TestUtils.jsm" +); + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +server.registerPathHandler("/redirect", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", params.get("redirect_uri")); + response.write("redirecting"); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +function onStopListener(channel) { + return new Promise(resolve => { + let orig = channel.QueryInterface(Ci.nsITraceableChannel).setNewListener({ + QueryInterface: ChromeUtils.generateQI([ + "nsIRequestObserver", + "nsIStreamListener", + ]), + getFinalURI(request) { + let { loadInfo } = request; + return (loadInfo && loadInfo.resultPrincipalURI) || request.originalURI; + }, + onDataAvailable(...args) { + orig.onDataAvailable(...args); + }, + onStartRequest(request) { + orig.onStartRequest(request); + }, + onStopRequest(request, statusCode) { + orig.onStopRequest(request, statusCode); + let URI = this.getFinalURI(request.QueryInterface(Ci.nsIChannel)); + resolve(URI && URI.spec); + }, + }); + }); +} + +async function onModifyListener(originUrl, redirectToUrl) { + return TestUtils.topicObserved("http-on-modify-request", (subject, data) => { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + return channel.URI && channel.URI.spec == originUrl; + }).then(([subject, data]) => { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + if (redirectToUrl) { + channel.redirectTo(Services.io.newURI(redirectToUrl)); + } + return channel; + }); +} + +function getExtension( + accessible = false, + background = undefined, + blocking = true +) { + let manifest = { + permissions: ["webRequest", "<all_urls>"], + }; + if (blocking) { + manifest.permissions.push("webRequestBlocking"); + } + if (accessible) { + manifest.web_accessible_resources = ["finished.html"]; + } + if (!background) { + background = () => { + // send the extensions public uri to the test. + let exturi = browser.extension.getURL("finished.html"); + browser.test.sendMessage("redirectURI", exturi); + }; + } + return ExtensionTestUtils.loadExtension({ + manifest, + files: { + "finished.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>redirected!</h1> + </body> + </html> + `.trim(), + }, + background, + }); +} + +async function redirection_test(url, channelRedirectUrl) { + // setup our observer + let watcher = onModifyListener(url, channelRedirectUrl).then(channel => { + return onStopListener(channel); + }); + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.send(); + return watcher; +} + +// This test verifies failure without web_accessible_resources. +add_task(async function test_redirect_to_non_accessible_resource() { + let extension = getExtension(); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let result = await redirection_test(url); + equal(result, url, `expected no redirect`); + await extension.unload(); +}); + +// This test makes a request against a server that redirects with a 302. +add_task(async function test_302_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let result = await redirection_test(url); + equal(result, redirectUrl, "redirect request is finished"); + await extension.unload(); +}); + +// This test uses channel.redirectTo during http-on-modify to redirect to the +// moz-extension url. +add_task(async function test_channel_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + let result = await redirection_test(url, redirectUrl); + equal(result, redirectUrl, "redirect request is finished"); + await extension.unload(); +}); + +// This test verifies failure without web_accessible_resources. +add_task(async function test_content_redirect_to_non_accessible_resource() { + let extension = getExtension(); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let watcher = onModifyListener(url).then(channel => { + return onStopListener(channel); + }); + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl: "about:blank", + }); + equal( + contentPage.browser.documentURI.spec, + "about:blank", + `expected no redirect` + ); + equal(await watcher, url, "expected no redirect"); + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server that redirects with a 302. +add_task(async function test_content_302_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await contentPage.close(); + await extension.unload(); +}); + +// This test uses channel.redirectTo during http-on-modify to redirect to the +// moz-extension url. +add_task(async function test_content_channel_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + onModifyListener(url, redirectUrl); + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page. +add_task(async function test_extension_302_redirect_web() { + function background(serverUrl) { + let expectedUrls = ["/redirect", "/dummy"]; + let expected = [ + "onBeforeRequest", + "onHeadersReceived", + "onBeforeRedirect", + "onBeforeRequest", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + ]; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertTrue( + details.url.includes(expectedUrls.shift()), + "onBeforeRequest url matches" + ); + browser.test.assertEq( + expected.shift(), + "onBeforeRequest", + "onBeforeRequest matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.assertEq( + expected.shift(), + "onHeadersReceived", + "onHeadersReceived matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onResponseStarted.addListener( + details => { + browser.test.assertEq( + expected.shift(), + "onResponseStarted", + "onResponseStarted matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertTrue( + details.redirectUrl.includes("/dummy"), + "onBeforeRedirect matches redirectUrl" + ); + browser.test.assertEq( + expected.shift(), + "onBeforeRedirect", + "onBeforeRedirect matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertTrue( + details.url.includes("/dummy"), + "onCompleted expected url received" + ); + browser.test.assertEq( + expected.shift(), + "onCompleted", + "onCompleted matches" + ); + browser.test.notifyPass("requestCompleted"); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*")`, + false + ); + await extension.startup(); + let redirectUrl = `${gServerUrl}/dummy`; + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_opening() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onBeforeRequest", + url: `${gServerUrl}/redirect`, + }, + { + event: "onBeforeRequest", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onBeforeRequest.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onBeforeRequest", + "onBeforeRequest event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onBeforeRequest url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_modify() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onHeadersReceived", + url: `${gServerUrl}/redirect`, + }, + { + event: "onHeadersReceived", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onHeadersReceived.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onHeadersReceived", + "onHeadersReceived event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onHeadersReceived url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: ["<all_urls>"] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_tracing() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onCompleted", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onCompleted.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onCompleted", + "onCompleted event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onCompleted url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests webrequest. Currently +// disabled due to NS_BINDING_ABORTED happening. +add_task(async function test_extension_302_redirect() { + let extension = getExtension(true, () => { + let myuri = browser.extension.getURL("*"); + let exturi = browser.extension.getURL("finished.html"); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertEq(details.redirectUrl, exturi, "redirect matches"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertEq(details.url, exturi, "expected url received"); + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + // send the extensions public uri to the test. + browser.test.sendMessage("redirectURI", exturi); + }); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}).skip(); + +// This test makes a request and uses onBeforeRequet to redirect to moz-ext. +// Currently disabled due to NS_BINDING_ABORTED happening. +add_task(async function test_extension_redirect() { + let extension = getExtension(true, () => { + let myuri = browser.extension.getURL("*"); + let exturi = browser.extension.getURL("finished.html"); + browser.webRequest.onBeforeRequest.addListener( + details => { + return { redirectUrl: exturi }; + }, + { urls: ["<all_urls>", myuri] }, + ["blocking"] + ); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertEq(details.redirectUrl, exturi, "redirect matches"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertEq(details.url, exturi, "expected url received"); + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + // send the extensions public uri to the test. + browser.test.sendMessage("redirectURI", exturi); + }); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await completed; + await contentPage.close(); + await extension.unload(); +}).skip(); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js new file mode 100644 index 0000000000..e42f45c019 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js @@ -0,0 +1,26 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_connect_without_listener() { + function background() { + let port = browser.runtime.connect(); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + port.error && port.error.message + ); + browser.test.notifyPass("port.onDisconnect was called"); + }); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("port.onDisconnect was called"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js new file mode 100644 index 0000000000..3f3b8f8e95 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +add_task(async function setup() { + ExtensionTestUtils.mockAppInfo(); +}); + +add_task(async function test_getBrowserInfo() { + async function background() { + let info = await browser.runtime.getBrowserInfo(); + + browser.test.assertEq(info.name, "XPCShell", "name is valid"); + browser.test.assertEq(info.vendor, "Mozilla", "vendor is Mozilla"); + browser.test.assertEq(info.version, "48", "version is correct"); + browser.test.assertEq(info.buildID, "20160315", "buildID is correct"); + + browser.test.notifyPass("runtime.getBrowserInfo"); + } + + const extension = ExtensionTestUtils.loadExtension({ background }); + await extension.startup(); + await extension.awaitFinish("runtime.getBrowserInfo"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js new file mode 100644 index 0000000000..8f213b0dec --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js @@ -0,0 +1,36 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + browser.runtime.getPlatformInfo(info => { + let validOSs = ["mac", "win", "android", "cros", "linux", "openbsd"]; + let validArchs = [ + "aarch64", + "arm", + "ppc64", + "s390x", + "sparc64", + "x86-32", + "x86-64", + ]; + + browser.test.assertTrue(validOSs.includes(info.os), "OS is valid"); + browser.test.assertTrue( + validArchs.includes(info.arch), + "Architecture is valid" + ); + browser.test.notifyPass("runtime.getPlatformInfo"); + }); +} + +let extensionData = { + background: backgroundScript, +}; + +add_task(async function() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("runtime.getPlatformInfo"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js new file mode 100644 index 0000000000..6967e81232 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js @@ -0,0 +1,46 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_runtime_id() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + + background() { + browser.test.sendMessage("background-id", browser.runtime.id); + }, + + files: { + "content_script.js"() { + browser.test.sendMessage("content-id", browser.runtime.id); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let backgroundId = await extension.awaitMessage("background-id"); + equal( + backgroundId, + extension.id, + "runtime.id from background script is correct" + ); + + let contentId = await extension.awaitMessage("content-id"); + equal(contentId, extension.id, "runtime.id from content script is correct"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js new file mode 100644 index 0000000000..6d71758a38 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js @@ -0,0 +1,84 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task( + async function test_messaging_to_self_should_not_trigger_onMessage_onConnect() { + async function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("msg from child", msg); + browser.test.sendMessage( + "sendMessage did not call same-frame onMessage" + ); + }); + + browser.test.onMessage.addListener(msg => { + browser.test.assertEq( + "sendMessage with a listener in another frame", + msg + ); + browser.runtime.sendMessage("should only reach another frame"); + }); + + await browser.test.assertRejects( + browser.runtime.sendMessage("should not trigger same-frame onMessage"), + "Could not establish connection. Receiving end does not exist." + ); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("from-frame", port.name); + browser.runtime.connect({ name: "from-bg-2" }); + }); + + await new Promise(resolve => { + let port = browser.runtime.connect({ name: "from-bg-1" }); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + port.error.message + ); + resolve(); + }); + }); + + let anotherFrame = document.createElement("iframe"); + anotherFrame.src = browser.extension.getURL("extensionpage.html"); + document.body.appendChild(anotherFrame); + } + + function lastScript() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("should only reach another frame", msg); + browser.runtime.sendMessage("msg from child"); + }); + browser.test.sendMessage("sendMessage callback called"); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("from-bg-2", port.name); + browser.test.sendMessage("connect did not call same-frame onConnect"); + }); + browser.runtime.connect({ name: "from-frame" }); + } + + let extensionData = { + background, + files: { + "lastScript.js": lastScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="lastScript.js"></script>`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("sendMessage callback called"); + extension.sendMessage("sendMessage with a listener in another frame"); + + await Promise.all([ + extension.awaitMessage("connect did not call same-frame onConnect"), + extension.awaitMessage("sendMessage did not call same-frame onMessage"), + ]); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js new file mode 100644 index 0000000000..7c54389b39 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js @@ -0,0 +1,401 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseAddonEvent, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +// Ensure that the background page is automatically started after using +// promiseStartupManager. +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +function background() { + let onInstalledDetails = null; + let onStartupFired = false; + + browser.runtime.onInstalled.addListener(details => { + onInstalledDetails = details; + }); + + browser.runtime.onStartup.addListener(() => { + onStartupFired = true; + }); + + browser.test.onMessage.addListener(message => { + if (message === "get-on-installed-details") { + onInstalledDetails = onInstalledDetails || { fired: false }; + browser.test.sendMessage("on-installed-details", onInstalledDetails); + } else if (message === "did-on-startup-fire") { + browser.test.sendMessage("on-startup-fired", onStartupFired); + } else if (message === "reload-extension") { + browser.runtime.reload(); + } + }); + + browser.runtime.onUpdateAvailable.addListener(details => { + browser.test.sendMessage("reloading"); + browser.runtime.reload(); + }); +} + +async function expectEvents( + extension, + { + onStartupFired, + onInstalledFired, + onInstalledReason, + onInstalledTemporary, + onInstalledPrevious, + } +) { + extension.sendMessage("get-on-installed-details"); + let details = await extension.awaitMessage("on-installed-details"); + if (onInstalledFired) { + equal( + details.reason, + onInstalledReason, + "runtime.onInstalled fired with the correct reason" + ); + equal( + details.temporary, + onInstalledTemporary, + "runtime.onInstalled fired with the correct temporary flag" + ); + if (onInstalledPrevious) { + equal( + details.previousVersion, + onInstalledPrevious, + "runtime.onInstalled after update with correct previousVersion" + ); + } + } else { + equal( + details.fired, + onInstalledFired, + "runtime.onInstalled should not have fired" + ); + } + + extension.sendMessage("did-on-startup-fire"); + let fired = await extension.awaitMessage("on-startup-fired"); + equal( + fired, + onStartupFired, + `Expected runtime.onStartup to ${onStartupFired ? "" : "not "} fire` + ); +} + +add_task(async function test_should_fire_on_addon_update() { + Preferences.set("extensions.logging.enabled", false); + + await promiseStartupManager(); + + const EXTENSION_ID = + "test_runtime_on_installed_addon_update@tests.mozilla.org"; + + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + update_url: `http://localhost:${port}/test_update.json`, + }, + }, + }, + background, + }); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_runtime_on_installed-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + testServer.registerFile( + "/addons/test_runtime_on_installed-2.0.xpi", + webExtensionFile + ); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + equal(addon.version, "1.0", "The installed addon has the correct version"); + + let update = await promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + + let promiseInstalled = promiseAddonEvent("onInstalled"); + await promiseCompleteAllInstalls([install]); + + await extension.awaitMessage("reloading"); + + let [updated_addon] = await promiseInstalled; + equal( + updated_addon.version, + "2.0", + "The updated addon has the correct version" + ); + + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "update", + onInstalledPrevious: "1.0", + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_should_fire_on_browser_update() { + const EXTENSION_ID = + "test_runtime_on_installed_browser_update@tests.mozilla.org"; + + await promiseStartupManager("1"); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + // Restart the browser. + await promiseRestartManager("1"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser. + await promiseRestartManager("2"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + // Restart the browser. + await promiseRestartManager("2"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser again. + await promiseRestartManager("3"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_should_not_fire_on_reload() { + const EXTENSION_ID = "test_runtime_on_installed_reload@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + extension.sendMessage("reload-extension"); + extension.setRestarting(); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_should_not_fire_on_restart() { + const EXTENSION_ID = "test_runtime_on_installed_restart@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.disable(); + await addon.enable(); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.markUnloaded(); + await promiseShutdownManager(); +}); + +add_task(async function test_temporary_installation() { + const EXTENSION_ID = + "test_runtime_on_installed_addon_temporary@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js new file mode 100644 index 0000000000..7365a13f93 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js @@ -0,0 +1,69 @@ +"use strict"; + +add_task(async function test_port_disconnected_from_wrong_window() { + let extensionData = { + background() { + let num = 0; + let ports = {}; + let done = false; + + browser.runtime.onConnect.addListener(port => { + num++; + ports[num] = port; + + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "port-2-response", "Got port 2 response"); + browser.test.sendMessage(msg + "-received"); + done = true; + }); + + port.onDisconnect.addListener(err => { + if (port === ports[1]) { + browser.test.log("Port 1 disconnected, sending message via port 2"); + ports[2].postMessage("port-2-msg"); + } else { + browser.test.assertTrue( + done, + "Port 2 disconnected only after a full roundtrip received" + ); + } + }); + + browser.test.sendMessage("port-connect-" + num); + }); + }, + files: { + "page.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "script.js"() { + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "port-2-msg", "Got message via port 2"); + port.postMessage("port-2-response"); + }); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + let url = `moz-extension://${extension.uuid}/page.html`; + await extension.startup(); + + let page1 = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("port-connect-1"); + info("First page opened port 1"); + + let page2 = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("port-connect-2"); + info("Second page opened port 2"); + + info("Closing the first page should not close port 2"); + await page1.close(); + await extension.awaitMessage("port-2-response-received"); + info("Roundtrip message through port 2 received"); + + await page2.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js new file mode 100644 index 0000000000..7b0cf01d08 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js @@ -0,0 +1,168 @@ +"use strict"; + +let gcExperimentAPIs = { + gcHelper: { + schema: "schema.json", + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["gcHelper"]], + }, + }, +}; + +let gcExperimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "gcHelper", + functions: [ + { + name: "forceGarbageCollect", + type: "function", + parameters: [], + async: true, + }, + { + name: "registerWitness", + type: "function", + parameters: [ + { + name: "obj", + // Expected type is "object", but using "any" here to ensure that + // the parameter is untouched (not normalized). + type: "any", + }, + ], + returns: { type: "number" }, + }, + { + name: "isGarbageCollected", + type: "function", + parameters: [ + { + name: "witnessId", + description: "return value of registerWitness", + type: "number", + }, + ], + returns: { type: "boolean" }, + }, + ], + }, + ]), + "child.js": () => { + let { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm"); + /* globals ExtensionAPI */ + this.gcHelper = class extends ExtensionAPI { + getAPI(context) { + let witnesses = new Map(); + return { + gcHelper: { + async forceGarbageCollect() { + // Logic copied from test_ext_contexts_gc.js + for (let i = 0; i < 3; ++i) { + Cu.forceShrinkingGC(); + Cu.forceCC(); + Cu.forceGC(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + }, + registerWitness(obj) { + let witnessId = witnesses.size; + witnesses.set(witnessId, Cu.getWeakReference(obj)); + return witnessId; + }, + isGarbageCollected(witnessId) { + return witnesses.get(witnessId).get() === null; + }, + }, + }; + } + }; + }, +}; + +// Verify that the experiment is working as intended before using it in tests. +add_task(async function test_gc_experiment() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + experiment_apis: gcExperimentAPIs, + }, + files: gcExperimentFiles, + async background() { + let obj1 = {}; + let obj2 = {}; + let witness1 = browser.gcHelper.registerWitness(obj1); + let witness2 = browser.gcHelper.registerWitness(obj2); + obj1 = null; + await browser.gcHelper.forceGarbageCollect(); + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witness1), + "obj1 should have been garbage-collected" + ); + browser.test.assertFalse( + browser.gcHelper.isGarbageCollected(witness2), + "obj2 should not have been garbage-collected" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_port_gc() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + experiment_apis: gcExperimentAPIs, + }, + files: gcExperimentFiles, + async background() { + let witnessPortSender; + let witnessPortReceiver; + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("daName", port.name, "expected port"); + witnessPortReceiver = browser.gcHelper.registerWitness(port); + port.disconnect(); + }); + + // runtime.connect() only triggers onConnect for different contexts, + // so create a frame to have a different context. + // A blank frame in a moz-extension:-document will have access to the + // extension APIs. + let frameWindow = await new Promise(resolve => { + let f = document.createElement("iframe"); + f.onload = () => resolve(f.contentWindow); + document.body.append(f); + }); + await new Promise(resolve => { + let port = frameWindow.browser.runtime.connect({ name: "daName" }); + witnessPortSender = browser.gcHelper.registerWitness(port); + port.onDisconnect.addListener(() => resolve()); + }); + + await browser.gcHelper.forceGarbageCollect(); + + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witnessPortSender), + "runtime.connect() port should have been garbage-collected" + ); + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witnessPortReceiver), + "runtime.onConnect port should have been garbage-collected" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js new file mode 100644 index 0000000000..a7404cf5dd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js @@ -0,0 +1,452 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function runtimeSendMessageReply() { + function background() { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { + respond(msg); + }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-promise-false") { + return Promise.resolve(false); + } else if (msg == "respond-false") { + // return false means that respond() is not expected to be called. + setTimeout(() => respond("should be ignored")); + return false; + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg == "throw-error") { + throw new Error(msg); + } else if (msg === "respond-uncloneable") { + return Promise.resolve(window); + } else if (msg === "reject-uncloneable") { + return Promise.reject(window); + } else if (msg == "reject-undefined") { + return Promise.reject(); + } else if (msg == "throw-undefined") { + throw undefined; // eslint-disable-line no-throw-literal + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + // If a response from another listener is received first, this + // exception should be ignored. Test fails if it is not. + + // All this is of course stupid, but some extensions depend on it. + msg.blah.this.throws(); + } + }); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); + } + + function senderScript() { + Promise.all([ + browser.runtime.sendMessage("respond-now"), + browser.runtime.sendMessage("respond-now-2"), + new Promise(resolve => + browser.runtime.sendMessage("respond-soon", resolve) + ), + browser.runtime.sendMessage("respond-promise"), + browser.runtime.sendMessage("respond-promise-false"), + browser.runtime.sendMessage("respond-false"), + browser.runtime.sendMessage("respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { + resolve(response); + }); + }), + + browser.runtime + .sendMessage("respond-error") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("throw-error") + .catch(error => Promise.resolve({ error })), + + browser.runtime + .sendMessage("respond-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("reject-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("reject-undefined") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("throw-undefined") + .catch(error => Promise.resolve({ error })), + ]) + .then( + ([ + respondNow, + respondNow2, + respondSoon, + respondPromise, + respondPromiseFalse, + respondFalse, + respondNever, + respondNever2, + respondError, + throwError, + respondUncloneable, + rejectUncloneable, + rejectUndefined, + throwUndefined, + ]) => { + browser.test.assertEq( + "respond-now", + respondNow, + "Got the expected immediate response" + ); + browser.test.assertEq( + "respond-now-2", + respondNow2, + "Got the expected immediate response from the second listener" + ); + browser.test.assertEq( + "respond-soon", + respondSoon, + "Got the expected delayed response" + ); + browser.test.assertEq( + "respond-promise", + respondPromise, + "Got the expected promise response" + ); + browser.test.assertEq( + false, + respondPromiseFalse, + "Got the expected false value as a promise result" + ); + browser.test.assertEq( + undefined, + respondFalse, + "Got the expected no-response when onMessage returns false" + ); + browser.test.assertEq( + undefined, + respondNever, + "Got the expected no-response resolution" + ); + browser.test.assertEq( + undefined, + respondNever2, + "Got the expected no-response resolution" + ); + + browser.test.assertEq( + "respond-error", + respondError.error.message, + "Got the expected error response" + ); + browser.test.assertEq( + "throw-error", + throwError.error.message, + "Got the expected thrown error response" + ); + + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + respondUncloneable.error.message, + "An uncloneable response should be ignored" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUncloneable.error.message, + "Got the expected error for a rejection with an uncloneable value" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUndefined.error.message, + "Got the expected error for a void rejection" + ); + browser.test.assertEq( + "An unexpected error occurred", + throwUndefined.error.message, + "Got the expected error for a void throw" + ); + + browser.test.notifyPass("sendMessage"); + } + ) + .catch(e => { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("sendMessage"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("sendMessage"); + await extension.unload(); +}); + +add_task(async function runtimeSendMessageBlob() { + function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertTrue(msg.blob instanceof Blob, "Message is a blob"); + return Promise.resolve(msg); + }); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); + } + + function senderScript() { + browser.runtime + .sendMessage({ blob: new Blob(["hello"]) }) + .then(response => { + browser.test.assertTrue( + response.blob instanceof Blob, + "Response is a blob" + ); + browser.test.notifyPass("sendBlob"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("sendBlob"); + await extension.unload(); +}); + +add_task(async function sendMessageResponseGC() { + function background() { + let savedResolve, savedRespond; + + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Got request: ${msg}`); + switch (msg) { + case "ping": + respond("pong"); + return; + + case "promise-save": + return new Promise(resolve => { + savedResolve = resolve; + }); + case "promise-resolve": + savedResolve("saved-resolve"); + return; + case "promise-never": + return new Promise(r => {}); + + case "callback-save": + savedRespond = respond; + return true; + case "callback-call": + savedRespond("saved-respond"); + return; + case "callback-never": + return true; + } + }); + + const frame = document.createElement("iframe"); + frame.src = "page.html"; + document.body.appendChild(frame); + } + + function page() { + browser.test.onMessage.addListener(msg => { + browser.runtime.sendMessage(msg).then( + response => { + if (response) { + browser.test.log(`Got response: ${response}`); + browser.test.sendMessage(response); + } + }, + error => { + browser.test.assertEq( + "Promised response from onMessage listener went out of scope", + error.message, + `Promise rejected with the correct error message` + ); + + browser.test.assertTrue( + /^moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js/.test(error.fileName), + `Promise rejected with the correct error filename: ${error.fileName}` + ); + + browser.test.assertEq( + 4, + error.lineNumber, + `Promise rejected with the correct error line number` + ); + + browser.test.assertTrue( + /moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js:4/.test(error.stack), + `Promise rejected with the correct error stack: ${error.stack}` + ); + browser.test.sendMessage("rejected"); + } + ); + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "page.html": + "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>", + "page.js": page, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Setup long-running tasks before GC. + extension.sendMessage("promise-save"); + extension.sendMessage("callback-save"); + + // Test returning a Promise that can never resolve. + extension.sendMessage("promise-never"); + + extension.sendMessage("ping"); + await extension.awaitMessage("pong"); + + Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false); + await extension.awaitMessage("rejected"); + + // Test returning `true` without holding the response handle. + extension.sendMessage("callback-never"); + + extension.sendMessage("ping"); + await extension.awaitMessage("pong"); + + Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false); + await extension.awaitMessage("rejected"); + + // Test that promises from long-running tasks didn't get GCd. + extension.sendMessage("promise-resolve"); + await extension.awaitMessage("saved-resolve"); + + extension.sendMessage("callback-call"); + await extension.awaitMessage("saved-respond"); + + ok("Long running tasks responded"); + await extension.unload(); +}); + +add_task(async function sendMessage_async_response_multiple_contexts() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Background got request: ${msg}`); + + switch (msg) { + case "ask-bg-fast": + respond("bg-respond"); + return true; + + case "ask-bg-slow": + return new Promise(r => setTimeout(() => r("bg-promise")), 1000); + } + }); + browser.test.sendMessage("bg-ready"); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["cs.js"], + }, + ], + }, + + files: { + "page.html": + "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>", + "page.js"() { + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Page got request: ${msg}`); + + switch (msg) { + case "ask-page-fast": + respond("page-respond"); + return true; + + case "ask-page-slow": + return new Promise(r => setTimeout(() => r("page-promise")), 500); + } + }); + browser.test.sendMessage("page-ready"); + }, + + "cs.js"() { + Promise.all([ + browser.runtime.sendMessage("ask-bg-fast"), + browser.runtime.sendMessage("ask-bg-slow"), + browser.runtime.sendMessage("ask-page-fast"), + browser.runtime.sendMessage("ask-page-slow"), + ]).then(responses => { + browser.test.assertEq( + responses.join(), + ["bg-respond", "bg-promise", "page-respond", "page-promise"].join(), + "Got all expected responses from correct contexts" + ); + browser.test.notifyPass("cs-done"); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ready"); + + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-ready"); + + let content = await ExtensionTestUtils.loadContentPage( + BASE_URL + "/file_sample.html" + ); + await extension.awaitFinish("cs-done"); + await content.close(); + + await page.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js new file mode 100644 index 0000000000..ecbaba5cfe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js @@ -0,0 +1,101 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function() { + const ID1 = "sendMessage1@tests.mozilla.org"; + const ID2 = "sendMessage2@tests.mozilla.org"; + + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener((...args) => { + browser.runtime.sendMessage(...args); + }); + + let frame = document.createElement("iframe"); + frame.src = "page.html"; + document.body.appendChild(frame); + }, + manifest: { applications: { gecko: { id: ID1 } } }, + files: { + "page.js": function() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.sendMessage("received-page", { msg, sender }); + }); + // Let them know we're done loading the page. + browser.test.sendMessage("page-ready"); + }, + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessageExternal.addListener((msg, sender) => { + browser.test.sendMessage("received-external", { msg, sender }); + }); + }, + manifest: { applications: { gecko: { id: ID2 } } }, + }); + + await Promise.all([extension1.startup(), extension2.startup()]); + await extension1.awaitMessage("page-ready"); + + // Check that a message was sent within extension1. + async function checkLocalMessage(msg) { + let result = await extension1.awaitMessage("received-page"); + deepEqual(result.msg, msg, "Received internal message"); + equal(result.sender.id, ID1, "Received correct sender id"); + } + + // Check that a message was sent from extension1 to extension2. + async function checkRemoteMessage(msg) { + let result = await extension2.awaitMessage("received-external"); + deepEqual(result.msg, msg, "Received cross-extension message"); + equal(result.sender.id, ID1, "Received correct sender id"); + } + + // sendMessage() takes 3 arguments: + // optional extensionID + // mandatory message + // optional options + // Due to this insane design we parse its arguments manually. This + // test is meant to cover all the combinations. + + // A single null or undefined argument is allowed, and represents the message + extension1.sendMessage(null); + await checkLocalMessage(null); + + // With one argument, it must be just the message + extension1.sendMessage("message"); + await checkLocalMessage("message"); + + // With two arguments, these cases should be treated as (extensionID, message) + extension1.sendMessage(ID2, "message"); + await checkRemoteMessage("message"); + + extension1.sendMessage(ID2, { msg: "message" }); + await checkRemoteMessage({ msg: "message" }); + + // And these should be (message, options) + extension1.sendMessage("message", {}); + await checkLocalMessage("message"); + + // or (message, non-callback), pick your poison + extension1.sendMessage("message", undefined); + await checkLocalMessage("message"); + + // With three arguments, we send a cross-extension message + extension1.sendMessage(ID2, "message", {}); + await checkRemoteMessage("message"); + + // Even when the last one is null or undefined + extension1.sendMessage(ID2, "message", undefined); + await checkRemoteMessage("message"); + + // The four params case is unambigous, so we allow null as a (non-) callback + extension1.sendMessage(ID2, "message", {}, null); + await checkRemoteMessage("message"); + + await Promise.all([extension1.unload(), extension2.unload()]); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js new file mode 100644 index 0000000000..a56c2fdc79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js @@ -0,0 +1,66 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sendMessage_error() { + async function background() { + let circ = {}; + circ.circ = circ; + let testCases = [ + // [arguments, expected error string], + [[], "runtime.sendMessage's message argument is missing"], + [ + [null, null, null, 42], + "runtime.sendMessage's last argument is not a function", + ], + [[null, null, 1], "runtime.sendMessage's options argument is invalid"], + [ + [1, null, null], + "runtime.sendMessage's extensionId argument is invalid", + ], + [ + [null, null, null, null, null], + "runtime.sendMessage received too many arguments", + ], + + // Even when the parameters are accepted, we still expect an error + // because there is no onMessage listener. + [ + [null, null, null], + "Could not establish connection. Receiving end does not exist.", + ], + + // Structured cloning doesn't work with DOM objects + [[null, location, null], "The object could not be cloned."], + [[null, [circ, location], null], "The object could not be cloned."], + ]; + + // Repeat all tests with the undefined value instead of null. + for (let [args, expectedError] of testCases.slice()) { + args = args.map(arg => (arg === null ? undefined : arg)); + testCases.push([args, expectedError]); + } + + for (let [args, expectedError] of testCases) { + let description = `runtime.sendMessage(${args.map(String).join(", ")})`; + + await browser.test.assertRejects( + browser.runtime.sendMessage(...args), + expectedError, + `expected error message for ${description}` + ); + } + + browser.test.notifyPass("sendMessage parameter validation"); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("sendMessage parameter validation"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js new file mode 100644 index 0000000000..9827a329e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js @@ -0,0 +1,67 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Regression test for bug 1655624: When there are multiple onMessage receivers +// that both handle the response asynchronously, destroying the context of one +// recipient should not prevent the other recipient from sending a reply. +add_task(async function onMessage_ignores_destroyed_contexts() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(async msg => { + if (msg !== "startTest") { + return; + } + try { + let res = await browser.runtime.sendMessage("msg_from_bg"); + browser.test.assertEq(0, res, "Result from onMessage"); + browser.test.notifyPass("handled_onMessage"); + } catch (e) { + browser.test.fail(`Unexpected error: ${e.message} :: ${e.stack}`); + browser.test.notifyFail("handled_onMessage"); + } + }); + }, + files: { + "tab.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="tab.js"></script> + `, + "tab.js": () => { + let where = location.search.slice(1); + let resolveOnMessage; + browser.runtime.onMessage.addListener(async msg => { + browser.test.assertEq("msg_from_bg", msg, `onMessage at ${where}`); + browser.test.sendMessage(`received:${where}`); + return new Promise(resolve => { + resolveOnMessage = resolve; + }); + }); + browser.test.onMessage.addListener(msg => { + if (msg === `resolveOnMessage:${where}`) { + resolveOnMessage(0); + } + }); + }, + }, + }); + await extension.startup(); + let tabToCloseEarly = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html?tabToCloseEarly`, + { extension } + ); + let tabToRespond = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html?tabToRespond`, + { extension } + ); + extension.sendMessage("startTest"); + await Promise.all([ + extension.awaitMessage("received:tabToCloseEarly"), + extension.awaitMessage("received:tabToRespond"), + ]); + await tabToCloseEarly.close(); + extension.sendMessage("resolveOnMessage:tabToRespond"); + await extension.awaitFinish("handled_onMessage"); + await tabToRespond.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js new file mode 100644 index 0000000000..23d8b05f83 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sendMessage_without_listener() { + async function background() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist.", + "Correct error when there are no receivers from background" + ); + + browser.test.sendMessage("sendMessage-error-bg"); + } + let extensionData = { + background, + files: { + "page.html": `<!doctype><meta charset=utf-8><script src="page.js"></script>`, + async "page.js"() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist.", + "Correct error when there are no receivers from extension page" + ); + + browser.test.notifyPass("sendMessage-error-page"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("sendMessage-error-bg"); + + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitFinish("sendMessage-error-page"); + await page.close(); + + await extension.unload(); +}); + +add_task(async function test_chrome_sendMessage_without_listener() { + function background() { + /* globals chrome */ + browser.test.assertEq( + null, + chrome.runtime.lastError, + "no lastError before call" + ); + let retval = chrome.runtime.sendMessage("msg"); + browser.test.assertEq( + null, + chrome.runtime.lastError, + "no lastError after call" + ); + browser.test.assertEq( + undefined, + retval, + "return value of chrome.runtime.sendMessage without callback" + ); + + let isAsyncCall = false; + retval = chrome.runtime.sendMessage("msg", reply => { + browser.test.assertEq(undefined, reply, "no reply"); + browser.test.assertTrue( + isAsyncCall, + "chrome.runtime.sendMessage's callback must be called asynchronously" + ); + browser.test.assertEq( + undefined, + retval, + "return value of chrome.runtime.sendMessage with callback" + ); + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + chrome.runtime.lastError.message + ); + browser.test.notifyPass("finished chrome.runtime.sendMessage"); + }); + isAsyncCall = true; + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("finished chrome.runtime.sendMessage"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js new file mode 100644 index 0000000000..80641d7be4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js @@ -0,0 +1,131 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +const WIN = `<html><body>dummy page setting a same-site cookie</body></html>`; + +// Small red image. +const IMG_BYTES = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12" + + "P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" +); + +server.registerPathHandler("/same_site_cookies", (request, response) => { + // avoid confusing cache behaviors + response.setHeader("Cache-Control", "no-cache", false); + + if (request.queryString === "loadWin") { + response.write(WIN); + return; + } + + // using startsWith and discard the math random + if (request.queryString.startsWith("loadImage")) { + response.setHeader( + "Set-Cookie", + "myKey=mySameSiteExtensionCookie; samesite=strict", + true + ); + response.setHeader("Content-Type", "image/png"); + response.write(IMG_BYTES); + return; + } + + if (request.queryString === "loadXHR") { + let cookie = "noCookie"; + if (request.hasHeader("Cookie")) { + cookie = request.getHeader("Cookie"); + } + response.setHeader("Content-Type", "text/plain"); + response.write(cookie); + return; + } + + // We should never get here, but just in case return something unexpected. + response.write("D'oh"); +}); + +/* Description of the test: + * (1) We load an image from mochi.test which sets a same site cookie + * (2) We have the web extension perform an XHR request to mochi.test + * (3) We verify the web-extension can access the same-site cookie + */ + +add_task(async function test_webRequest_same_site_cookie_access() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + content_scripts: [ + { + matches: ["http://example.com/*"], + run_at: "document_end", + js: ["content_script.js"], + }, + ], + }, + + background() { + browser.test.onMessage.addListener(msg => { + if (msg === "verify-same-site-cookie-moz-extension") { + let xhr = new XMLHttpRequest(); + try { + xhr.open( + "GET", + "http://example.com/same_site_cookies?loadXHR", + true + ); + xhr.onload = function() { + browser.test.assertEq( + "myKey=mySameSiteExtensionCookie", + xhr.responseText, + "cookie should be accessible from moz-extension context" + ); + browser.test.sendMessage("same-site-cookie-test-done"); + }; + xhr.onerror = function() { + browser.test.fail("xhr onerror"); + browser.test.sendMessage("same-site-cookie-test-done"); + }; + } catch (e) { + browser.test.fail("xhr failure: " + e); + } + xhr.send(); + } + }); + }, + + files: { + "content_script.js": function() { + let myImage = document.createElement("img"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + myImage.wrappedJSObject.setAttribute( + "src", + "http://example.com/same_site_cookies?loadImage" + Math.random() + ); + myImage.onload = function() { + browser.test.log("image onload"); + browser.test.sendMessage("image-loaded-and-same-site-cookie-set"); + }; + myImage.onerror = function() { + browser.test.log("image onerror"); + }; + document.body.appendChild(myImage); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/same_site_cookies?loadWin" + ); + + await extension.awaitMessage("image-loaded-and-same-site-cookie-set"); + + extension.sendMessage("verify-same-site-cookie-moz-extension"); + await extension.awaitMessage("same-site-cookie-test-done"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js new file mode 100644 index 0000000000..df77f8b0dd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js @@ -0,0 +1,233 @@ +"use strict"; + +/** + * This test tests various redirection scenarios, and checks whether sameSite + * cookies are sent. + * + * The file has the following tests: + * - verify_firstparty_web_behavior - base case, confirms normal web behavior. + * - samesite_is_foreign_without_host_permissions + * - wildcard_host_permissions_enable_samesite_cookies + * - explicit_host_permissions_enable_samesite_cookies + * - some_host_permissions_enable_some_samesite_cookies + */ + +// This simulates a common pattern used for sites that require authentication. +// After logging in, there may be multiple redirects, HTTP and scripted. +const SITE_START = "start.example.net"; +// set "start" cookies + 302 redirects to found. +const SITE_FOUND = "found.example.net"; +// set "found" cookies + uses a HTML redirect to redir. +const SITE_REDIR = "redir.example.net"; +// set "redir" cookies + 302 redirects to final. +const SITE_FINAL = "final.example.net"; + +const SITE = "example.net"; + +const URL_START = `http://${SITE_START}/start`; + +const server = createHttpServer({ + hosts: [SITE_START, SITE_FOUND, SITE_REDIR, SITE_FINAL], +}); + +function getCookies(request) { + return request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; +} + +function sendCookies(response, prefix, suffix = "") { + const cookies = [ + prefix + "-none=1; sameSite=none; domain=" + SITE + suffix, + prefix + "-lax=1; sameSite=lax; domain=" + SITE + suffix, + prefix + "-strict=1; sameSite=strict; domain=" + SITE + suffix, + ]; + for (let cookie of cookies) { + response.setHeader("Set-Cookie", cookie, true); + } +} + +function deleteCookies(response, prefix) { + sendCookies(response, prefix, "; expires=Thu, 01 Jan 1970 00:00:00 GMT"); +} + +var receivedCookies = []; + +server.registerPathHandler("/start", (request, response) => { + Assert.equal(request.host, SITE_START); + Assert.equal(getCookies(request), "", "No cookies at start of test"); + + response.setStatusLine(request.httpVersion, 302, "Found"); + sendCookies(response, "start"); + response.setHeader("Location", `http://${SITE_FOUND}/found`); +}); + +server.registerPathHandler("/found", (request, response) => { + Assert.equal(request.host, SITE_FOUND); + receivedCookies.push(getCookies(request)); + + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + deleteCookies(response, "start"); + sendCookies(response, "found"); + response.write(`<script>location = "http://${SITE_REDIR}/redir";</script>`); +}); + +server.registerPathHandler("/redir", (request, response) => { + Assert.equal(request.host, SITE_REDIR); + receivedCookies.push(getCookies(request)); + + response.setStatusLine(request.httpVersion, 302, "Found"); + deleteCookies(response, "found"); + sendCookies(response, "redir"); + response.setHeader("Location", `http://${SITE_FINAL}/final`); +}); + +server.registerPathHandler("/final", (request, response) => { + Assert.equal(request.host, SITE_FINAL); + receivedCookies.push(getCookies(request)); + + response.setStatusLine(request.httpVersion, 302, "Found"); + deleteCookies(response, "redir"); + // In test some_host_permissions_enable_some_samesite_cookies, the cookies + // from the start haven't been cleared due to the lack of host permissions. + // Do that here instead. + deleteCookies(response, "start"); + response.setHeader("Location", "/final_and_clean"); +}); + +// Should be called before any request is made. +function promiseFinalResponse() { + Assert.deepEqual(receivedCookies, [], "Test starts without observed cookies"); + return new Promise(resolve => { + server.registerPathHandler("/final_and_clean", (request, response) => { + Assert.equal(request.host, SITE_FINAL); + Assert.equal(getCookies(request), "", "Cookies cleaned up"); + resolve(receivedCookies.splice(0)); + }); + }); +} + +// Load the page as a child frame of an extension, for the given permissions. +async function getCookiesForLoadInExtension({ permissions }) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + files: { + "embedder.html": `<iframe src="${URL_START}"></iframe>`, + }, + }); + await extension.startup(); + let cookiesPromise = promiseFinalResponse(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/embedder.html`, + { extension } + ); + let cookies = await cookiesPromise; + await contentPage.close(); + await extension.unload(); + return cookies; +} + +add_task(async function setup() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true); + + // Test server runs on http, so disable Secure requirement of sameSite=none. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); +}); + +// First verify that our expectations match with the actual behavior on the web. +add_task(async function verify_firstparty_web_behavior() { + let cookiesPromise = promiseFinalResponse(); + let contentPage = await ExtensionTestUtils.loadContentPage(URL_START); + let cookies = await cookiesPromise; + await contentPage.close(); + Assert.deepEqual( + cookies, + // Same expectations as in host_permissions_enable_samesite_cookies + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a first-party load on the web" + ); +}); + +// Verify that an extension without permission behaves like a third-party page. +add_task(async function samesite_is_foreign_without_host_permissions() { + let cookies = await getCookiesForLoadInExtension({ + permissions: [], + }); + + Assert.deepEqual( + cookies, + ["start-none=1", "found-none=1", "redir-none=1"], + "SameSite cookies excluded without permissions" + ); +}); + +// When an extension has permissions for the site, cookies should be included. +add_task(async function wildcard_host_permissions_enable_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: ["*://*.example.net/*"], // = *.SITE + }); + + Assert.deepEqual( + cookies, + // Same expectations as in verify_firstparty_web_behavior. + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a load in an extension frame" + ); +}); + +// When an extension has permissions for the site, cookies should be included. +add_task(async function explicit_host_permissions_enable_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: [ + "*://start.example.net/*", + "*://found.example.net/*", + "*://redir.example.net/*", + "*://final.example.net/*", + ], + }); + + Assert.deepEqual( + cookies, + // Same expectations as in verify_firstparty_web_behavior. + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a load in an extension frame" + ); +}); + +// When an extension does not have host permissions for all sites, but only +// some, then same-site cookies are only included in requests with the right +// permissions. +add_task(async function some_host_permissions_enable_some_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: ["*://start.example.net/*", "*://final.example.net/*"], + }); + + Assert.deepEqual( + cookies, + [ + // Missing permission for "found.example.net": + "start-none=1", + // Missing permission for "redir.example.net": + "found-none=1", + // "final.example.net" can see cookies from "start.example.net": + "start-lax=1; start-strict=1; redir-none=1", + ], + "Expected some cookies from a load in an extension frame" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js new file mode 100644 index 0000000000..0a8a5acdef --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js @@ -0,0 +1,42 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function contentScript() { + window.x = 12; + browser.test.assertEq(window.x, 12, "x is 12"); + browser.test.notifyPass("background test passed"); +} + +let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish(); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js new file mode 100644 index 0000000000..90b615d10e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js @@ -0,0 +1,79 @@ +"use strict"; + +AddonTestUtils.init(this); + +add_task(async function testEmptySchema() { + function background() { + browser.test.assertEq( + undefined, + browser.manifest, + "browser.manifest is not defined" + ); + browser.test.assertTrue( + !!browser.storage, + "browser.storage should be defined" + ); + browser.test.assertEq( + undefined, + browser.contextMenus, + "browser.contextMenus should not be defined" + ); + browser.test.notifyPass("schema"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["storage"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("schema"); + await extension.unload(); +}); + +add_task(async function test_warnings_as_errors() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { unrecognized_property_that_should_be_treated_as_a_warning: 1 }, + }); + + // Tests should be run with extensions.webextensions.warnings-as-errors=true + // by default, and prevent extensions with manifest warnings from loading. + await Assert.rejects( + extension.startup(), + /unrecognized_property_that_should_be_treated_as_a_warning/, + "extension with invalid manifest should not load if warnings-as-errors=true" + ); + // When ExtensionTestUtils.failOnSchemaWarnings(false) is called, startup is + // expected to succeed, as shown by the next "testUnknownProperties" test. +}); + +add_task(async function testUnknownProperties() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unknownPermission"], + + unknown_property: {}, + }, + + background() {}, + }); + + let { messages } = await promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /processing permissions\.0: Value "unknownPermission"/ }, + { + message: /processing unknown_property: An unexpected property was found in the WebExtension manifest/, + }, + ], + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js new file mode 100644 index 0000000000..8eba1b7e83 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js @@ -0,0 +1,2097 @@ +"use strict"; + +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); + +let { LocalAPIImplementation, SchemaAPIInterface } = ExtensionCommon; + +const global = this; + +let json = [ + { + namespace: "testing", + + properties: { + PROP1: { value: 20 }, + prop2: { type: "string" }, + prop3: { + $ref: "submodule", + }, + prop4: { + $ref: "submodule", + unsupported: true, + }, + }, + + types: [ + { + id: "type1", + type: "string", + enum: ["value1", "value2", "value3"], + }, + + { + id: "type2", + type: "object", + properties: { + prop1: { type: "integer" }, + prop2: { type: "array", items: { $ref: "type1" } }, + }, + }, + + { + id: "basetype1", + type: "object", + properties: { + prop1: { type: "string" }, + }, + }, + + { + id: "basetype2", + choices: [{ type: "integer" }], + }, + + { + $extend: "basetype1", + properties: { + prop2: { type: "string" }, + }, + }, + + { + $extend: "basetype2", + choices: [{ type: "string" }], + }, + + { + id: "basetype3", + type: "object", + properties: { + baseprop: { type: "string" }, + }, + }, + + { + id: "derivedtype1", + type: "object", + $import: "basetype3", + properties: { + derivedprop: { type: "string" }, + }, + }, + + { + id: "derivedtype2", + type: "object", + $import: "basetype3", + properties: { + derivedprop: { type: "integer" }, + }, + }, + + { + id: "submodule", + type: "object", + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: { type: "integer" }, + }, + ], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true, default: 99 }, + { name: "arg2", type: "boolean", optional: true }, + ], + }, + + { + name: "bar", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true }, + { name: "arg2", type: "boolean" }, + ], + }, + + { + name: "baz", + type: "function", + parameters: [ + { + name: "arg1", + type: "object", + properties: { + prop1: { type: "string" }, + prop2: { type: "integer", optional: true }, + prop3: { type: "integer", unsupported: true }, + }, + }, + ], + }, + + { + name: "qux", + type: "function", + parameters: [{ name: "arg1", $ref: "type1" }], + }, + + { + name: "quack", + type: "function", + parameters: [{ name: "arg1", $ref: "type2" }], + }, + + { + name: "quora", + type: "function", + parameters: [{ name: "arg1", type: "function" }], + }, + + { + name: "quileute", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true }, + { name: "arg2", type: "integer" }, + ], + }, + + { + name: "queets", + type: "function", + unsupported: true, + parameters: [], + }, + + { + name: "quintuplets", + type: "function", + parameters: [ + { + name: "obj", + type: "object", + properties: [], + additionalProperties: { type: "integer" }, + }, + ], + }, + + { + name: "quasar", + type: "function", + parameters: [ + { + name: "abc", + type: "object", + properties: { + func: { + type: "function", + parameters: [{ name: "x", type: "integer" }], + }, + }, + }, + ], + }, + + { + name: "quosimodo", + type: "function", + parameters: [ + { + name: "xyz", + type: "object", + additionalProperties: { type: "any" }, + }, + ], + }, + + { + name: "patternprop", + type: "function", + parameters: [ + { + name: "obj", + type: "object", + properties: { prop1: { type: "string", pattern: "^\\d+$" } }, + patternProperties: { + "(?i)^prop\\d+$": { type: "string" }, + "^foo\\d+$": { type: "string" }, + }, + }, + ], + }, + + { + name: "pattern", + type: "function", + parameters: [ + { name: "arg", type: "string", pattern: "(?i)^[0-9a-f]+$" }, + ], + }, + + { + name: "format", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + hostname: { type: "string", format: "hostname", optional: true }, + url: { type: "string", format: "url", optional: true }, + relativeUrl: { + type: "string", + format: "relativeUrl", + optional: true, + }, + strictRelativeUrl: { + type: "string", + format: "strictRelativeUrl", + optional: true, + }, + imageDataOrStrictRelativeUrl: { + type: "string", + format: "imageDataOrStrictRelativeUrl", + optional: true, + }, + }, + }, + ], + }, + + { + name: "formatDate", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + date: { type: "string", format: "date", optional: true }, + }, + }, + ], + }, + + { + name: "deep", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { + type: "object", + properties: { + bar: { + type: "array", + items: { + type: "object", + properties: { + baz: { + type: "object", + properties: { + required: { type: "integer" }, + optional: { type: "string", optional: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + + { + name: "errors", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + warn: { + type: "string", + pattern: "^\\d+$", + optional: true, + onError: "warn", + }, + ignore: { + type: "string", + pattern: "^\\d+$", + optional: true, + onError: "ignore", + }, + default: { + type: "string", + pattern: "^\\d+$", + optional: true, + }, + }, + }, + ], + }, + + { + name: "localize", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { type: "string", preprocess: "localize", optional: true }, + bar: { type: "string", optional: true }, + url: { + type: "string", + preprocess: "localize", + format: "url", + optional: true, + }, + }, + }, + ], + }, + + { + name: "extended1", + type: "function", + parameters: [{ name: "val", $ref: "basetype1" }], + }, + + { + name: "extended2", + type: "function", + parameters: [{ name: "val", $ref: "basetype2" }], + }, + + { + name: "callderived1", + type: "function", + parameters: [{ name: "value", $ref: "derivedtype1" }], + }, + + { + name: "callderived2", + type: "function", + parameters: [{ name: "value", $ref: "derivedtype2" }], + }, + ], + + events: [ + { + name: "onFoo", + type: "function", + }, + + { + name: "onBar", + type: "function", + extraParameters: [ + { + name: "filter", + type: "integer", + optional: true, + default: 1, + }, + ], + }, + ], + }, + { + namespace: "foreign", + properties: { + foreignRef: { $ref: "testing.submodule" }, + }, + }, + { + namespace: "inject", + properties: { + PROP1: { value: "should inject" }, + }, + }, + { + namespace: "do-not-inject", + properties: { + PROP1: { value: "should not inject" }, + }, + }, +]; + +let tallied = null; + +function tally(kind, ns, name, args) { + tallied = [kind, ns, name, args]; +} + +function verify(...args) { + Assert.equal(JSON.stringify(tallied), JSON.stringify(args)); + tallied = null; +} + +let talliedErrors = []; + +function checkErrors(errors) { + Assert.equal( + talliedErrors.length, + errors.length, + "Got expected number of errors" + ); + for (let [i, error] of errors.entries()) { + Assert.ok( + i in talliedErrors && String(talliedErrors[i]).includes(error), + `${JSON.stringify(error)} is a substring of error ${JSON.stringify( + talliedErrors[i] + )}` + ); + } + + talliedErrors.length = 0; +} + +let permissions = new Set(); + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + callFunction(args) { + tally("call", this.namespace, this.name, args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(args) { + tally("call", this.namespace, this.name, args); + } + + getProperty() { + tally("get", this.namespace, this.name); + } + + setProperty(value) { + tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + tally("addListener", this.namespace, this.name, [listener, args]); + } + + removeListener(listener) { + tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + tally("hasListener", this.namespace, this.name, [listener]); + } +} + +let wrapper = { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + + cloneScope: global, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`); + }, + }, + + logError(message) { + talliedErrors.push(message); + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + shouldInject(ns, name) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(namespace, name); + }, +}; + +add_task(async function() { + let url = "data:," + JSON.stringify(json); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + tallied = null; + Schemas.inject(root, wrapper); + Assert.equal(tallied, null); + + Assert.equal(root.testing.PROP1, 20, "simple value property"); + Assert.equal(root.testing.type1.VALUE1, "value1", "enum type"); + Assert.equal(root.testing.type1.VALUE2, "value2", "enum type"); + + Assert.equal("inject" in root, true, "namespace 'inject' should be injected"); + Assert.equal( + root["do-not-inject"], + undefined, + "namespace 'do-not-inject' should not be injected" + ); + + root.testing.foo(11, true); + verify("call", "testing", "foo", [11, true]); + + root.testing.foo(true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(null, true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(undefined, true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(11); + verify("call", "testing", "foo", [11, null]); + + Assert.throws( + () => root.testing.bar(11), + /Incorrect argument types/, + "should throw without required arg" + ); + + Assert.throws( + () => root.testing.bar(11, true, 10), + /Incorrect argument types/, + "should throw with too many arguments" + ); + + root.testing.bar(true); + verify("call", "testing", "bar", [null, true]); + + root.testing.baz({ prop1: "hello", prop2: 22 }); + verify("call", "testing", "baz", [{ prop1: "hello", prop2: 22 }]); + + root.testing.baz({ prop1: "hello" }); + verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]); + + root.testing.baz({ prop1: "hello", prop2: null }); + verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]); + + Assert.throws( + () => root.testing.baz({ prop2: 12 }), + /Property "prop1" is required/, + "should throw without required property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: "hi", prop3: 12 }), + /Property "prop3" is unsupported by Firefox/, + "should throw with unsupported property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: "hi", prop4: 12 }), + /Unexpected property "prop4"/, + "should throw with unexpected property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: 12 }), + /Expected string instead of 12/, + "should throw with wrong type" + ); + + root.testing.qux("value2"); + verify("call", "testing", "qux", ["value2"]); + + Assert.throws( + () => root.testing.qux("value4"), + /Invalid enumeration value "value4"/, + "should throw for invalid enum value" + ); + + root.testing.quack({ prop1: 12, prop2: ["value1", "value3"] }); + verify("call", "testing", "quack", [ + { prop1: 12, prop2: ["value1", "value3"] }, + ]); + + Assert.throws( + () => + root.testing.quack({ prop1: 12, prop2: ["value1", "value3", "value4"] }), + /Invalid enumeration value "value4"/, + "should throw for invalid array type" + ); + + function f() {} + root.testing.quora(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quora"]) + ); + Assert.equal(tallied[3][0], f); + tallied = null; + + let g = () => 0; + root.testing.quora(g); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quora"]) + ); + Assert.equal(tallied[3][0], g); + tallied = null; + + root.testing.quileute(10); + verify("call", "testing", "quileute", [null, 10]); + + Assert.throws( + () => root.testing.queets(), + /queets is not a function/, + "should throw for unsupported functions" + ); + + root.testing.quintuplets({ a: 10, b: 20, c: 30 }); + verify("call", "testing", "quintuplets", [{ a: 10, b: 20, c: 30 }]); + + Assert.throws( + () => root.testing.quintuplets({ a: 10, b: 20, c: 30, d: "hi" }), + /Expected integer instead of "hi"/, + "should throw for wrong additionalProperties type" + ); + + root.testing.quasar({ func: f }); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quasar"]) + ); + Assert.equal(tallied[3][0].func, f); + tallied = null; + + root.testing.quosimodo({ a: 10, b: 20, c: 30 }); + verify("call", "testing", "quosimodo", [{ a: 10, b: 20, c: 30 }]); + tallied = null; + + Assert.throws( + () => root.testing.quosimodo(10), + /Incorrect argument types/, + "should throw for wrong type" + ); + + root.testing.patternprop({ + prop1: "12", + prop2: "42", + Prop3: "43", + foo1: "x", + }); + verify("call", "testing", "patternprop", [ + { prop1: "12", prop2: "42", Prop3: "43", foo1: "x" }, + ]); + tallied = null; + + root.testing.patternprop({ prop1: "12" }); + verify("call", "testing", "patternprop", [{ prop1: "12" }]); + tallied = null; + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", foo1: null }), + /Expected string instead of null/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "xx", prop2: "yy" }), + /String "xx" must match \/\^\\d\+\$\//, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", prop2: 42 }), + /Expected string instead of 42/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", prop2: null }), + /Expected string instead of null/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", propx: "42" }), + /Unexpected property "propx"/, + "should throw for unexpected property" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", Foo1: "x" }), + /Unexpected property "Foo1"/, + "should throw for unexpected property" + ); + + root.testing.pattern("DEADbeef"); + verify("call", "testing", "pattern", ["DEADbeef"]); + tallied = null; + + Assert.throws( + () => root.testing.pattern("DEADcow"), + /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/, + "should throw for non-match" + ); + + root.testing.format({ hostname: "foo" }); + verify("call", "testing", "format", [ + { + hostname: "foo", + imageDataOrStrictRelativeUrl: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + tallied = null; + + for (let invalid of ["", " ", "http://foo", "foo/bar", "foo.com/", "foo?"]) { + Assert.throws( + () => root.testing.format({ hostname: invalid }), + /Invalid hostname/, + "should throw for invalid hostname" + ); + } + + root.testing.format({ url: "http://foo/bar", relativeUrl: "http://foo/bar" }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: null, + relativeUrl: "http://foo/bar", + strictRelativeUrl: null, + url: "http://foo/bar", + }, + ]); + tallied = null; + + root.testing.format({ + relativeUrl: "foo.html", + strictRelativeUrl: "foo.html", + }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: null, + relativeUrl: `${wrapper.url}foo.html`, + strictRelativeUrl: `${wrapper.url}foo.html`, + url: null, + }, + ]); + tallied = null; + + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/png;base64,A", + }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: "data:image/png;base64,A", + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + tallied = null; + + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A", + }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A", + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + tallied = null; + + root.testing.format({ imageDataOrStrictRelativeUrl: "foo.html" }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: `${wrapper.url}foo.html`, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + + tallied = null; + + for (let format of ["url", "relativeUrl"]) { + Assert.throws( + () => root.testing.format({ [format]: "chrome://foo/content/" }), + /Access denied/, + "should throw for access denied" + ); + } + + for (let urlString of ["//foo.html", "http://foo/bar.html"]) { + Assert.throws( + () => root.testing.format({ strictRelativeUrl: urlString }), + /must be a relative URL/, + "should throw for non-relative URL" + ); + } + + Assert.throws( + () => + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/svg+xml;utf8,A", + }), + /must be a relative or PNG or JPG data:image URL/, + "should throw for non-relative or non PNG/JPG data URL" + ); + + const dates = [ + "2016-03-04", + "2016-03-04T08:00:00Z", + "2016-03-04T08:00:00.000Z", + "2016-03-04T08:00:00-08:00", + "2016-03-04T08:00:00.000-08:00", + "2016-03-04T08:00:00+08:00", + "2016-03-04T08:00:00.000+08:00", + "2016-03-04T08:00:00+0800", + "2016-03-04T08:00:00-0800", + ]; + dates.forEach(str => { + root.testing.formatDate({ date: str }); + verify("call", "testing", "formatDate", [{ date: str }]); + }); + + // Make sure that a trivial change to a valid date invalidates it. + dates.forEach(str => { + Assert.throws( + () => root.testing.formatDate({ date: "0" + str }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + Assert.throws( + () => root.testing.formatDate({ date: str + "0" }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + }); + + const badDates = [ + "I do not look anything like a date string", + "2016-99-99", + "2016-03-04T25:00:00Z", + ]; + badDates.forEach(str => { + Assert.throws( + () => root.testing.formatDate({ date: str }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + }); + + root.testing.deep({ + foo: { bar: [{ baz: { required: 12, optional: "42" } }] }, + }); + verify("call", "testing", "deep", [ + { foo: { bar: [{ baz: { optional: "42", required: 12 } }] } }, + ]); + tallied = null; + + Assert.throws( + () => root.testing.deep({ foo: { bar: [{ baz: { optional: "42" } }] } }), + /Type error for parameter arg \(Error processing foo\.bar\.0\.baz: Property "required" is required\) for testing\.deep/, + "should throw with the correct object path" + ); + + Assert.throws( + () => + root.testing.deep({ + foo: { bar: [{ baz: { optional: 42, required: 12 } }] }, + }), + /Type error for parameter arg \(Error processing foo\.bar\.0\.baz\.optional: Expected string instead of 42\) for testing\.deep/, + "should throw with the correct object path" + ); + + talliedErrors.length = 0; + + root.testing.errors({ default: "0123", ignore: "0123", warn: "0123" }); + verify("call", "testing", "errors", [ + { default: "0123", ignore: "0123", warn: "0123" }, + ]); + checkErrors([]); + + root.testing.errors({ default: "0123", ignore: "x123", warn: "0123" }); + verify("call", "testing", "errors", [ + { default: "0123", ignore: null, warn: "0123" }, + ]); + checkErrors([]); + + ExtensionTestUtils.failOnSchemaWarnings(false); + root.testing.errors({ default: "0123", ignore: "0123", warn: "x123" }); + ExtensionTestUtils.failOnSchemaWarnings(true); + verify("call", "testing", "errors", [ + { default: "0123", ignore: "0123", warn: null }, + ]); + checkErrors(['String "x123" must match /^\\d+$/']); + + root.testing.onFoo.addListener(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onFoo"]) + ); + Assert.equal(tallied[3][0], f); + Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([])); + tallied = null; + + root.testing.onFoo.removeListener(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["removeListener", "testing", "onFoo"]) + ); + Assert.equal(tallied[3][0], f); + tallied = null; + + root.testing.onFoo.hasListener(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["hasListener", "testing", "onFoo"]) + ); + Assert.equal(tallied[3][0], f); + tallied = null; + + Assert.throws( + () => root.testing.onFoo.addListener(10), + /Invalid listener/, + "addListener with non-function should throw" + ); + + root.testing.onBar.addListener(f, 10); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onBar"]) + ); + Assert.equal(tallied[3][0], f); + Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([10])); + tallied = null; + + root.testing.onBar.addListener(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onBar"]) + ); + Assert.equal(tallied[3][0], f); + Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([1])); + tallied = null; + + Assert.throws( + () => root.testing.onBar.addListener(f, "hi"), + /Incorrect argument types/, + "addListener with wrong extra parameter should throw" + ); + + let target = { prop1: 12, prop2: ["value1", "value3"] }; + let proxy = new Proxy(target, {}); + Assert.throws( + () => root.testing.quack(proxy), + /Expected a plain JavaScript object, got a Proxy/, + "should throw when passing a Proxy" + ); + + if (Symbol.toStringTag) { + let stringTarget = { prop1: 12, prop2: ["value1", "value3"] }; + stringTarget[Symbol.toStringTag] = () => "[object Object]"; + let stringProxy = new Proxy(stringTarget, {}); + Assert.throws( + () => root.testing.quack(stringProxy), + /Expected a plain JavaScript object, got a Proxy/, + "should throw when passing a Proxy" + ); + } + + root.testing.localize({ + foo: "__MSG_foo__", + bar: "__MSG_foo__", + url: "__MSG_http://example.com/__", + }); + verify("call", "testing", "localize", [ + { bar: "__MSG_foo__", foo: "FOO", url: "http://example.com/" }, + ]); + tallied = null; + + Assert.throws( + () => root.testing.localize({ url: "__MSG_/foo/bar__" }), + /\/FOO\/BAR is not a valid URL\./, + "should throw for invalid URL" + ); + + root.testing.extended1({ prop1: "foo", prop2: "bar" }); + verify("call", "testing", "extended1", [{ prop1: "foo", prop2: "bar" }]); + tallied = null; + + Assert.throws( + () => root.testing.extended1({ prop1: "foo", prop2: 12 }), + /Expected string instead of 12/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.extended1({ prop1: "foo" }), + /Property "prop2" is required/, + "should throw for missing property" + ); + + Assert.throws( + () => root.testing.extended1({ prop1: "foo", prop2: "bar", prop3: "xxx" }), + /Unexpected property "prop3"/, + "should throw for extra property" + ); + + root.testing.extended2("foo"); + verify("call", "testing", "extended2", ["foo"]); + tallied = null; + + root.testing.extended2(12); + verify("call", "testing", "extended2", [12]); + tallied = null; + + Assert.throws( + () => root.testing.extended2(true), + /Incorrect argument types/, + "should throw for wrong argument type" + ); + + root.testing.prop3.sub_foo(); + verify("call", "testing.prop3", "sub_foo", []); + tallied = null; + + Assert.throws( + () => root.testing.prop4.sub_foo(), + /root.testing.prop4 is undefined/, + "should throw for unsupported submodule" + ); + + root.foreign.foreignRef.sub_foo(); + verify("call", "foreign.foreignRef", "sub_foo", []); + tallied = null; + + root.testing.callderived1({ baseprop: "s1", derivedprop: "s2" }); + verify("call", "testing", "callderived1", [ + { baseprop: "s1", derivedprop: "s2" }, + ]); + tallied = null; + + Assert.throws( + () => root.testing.callderived1({ baseprop: "s1", derivedprop: 42 }), + /Error processing derivedprop: Expected string/, + "Two different objects may $import the same base object" + ); + Assert.throws( + () => root.testing.callderived1({ baseprop: "s1" }), + /Property "derivedprop" is required/, + "Object using $import has its local properites" + ); + Assert.throws( + () => root.testing.callderived1({ derivedprop: "s2" }), + /Property "baseprop" is required/, + "Object using $import has imported properites" + ); + + root.testing.callderived2({ baseprop: "s1", derivedprop: 42 }); + verify("call", "testing", "callderived2", [ + { baseprop: "s1", derivedprop: 42 }, + ]); + tallied = null; + + Assert.throws( + () => root.testing.callderived2({ baseprop: "s1", derivedprop: "s2" }), + /Error processing derivedprop: Expected integer/, + "Two different objects may $import the same base object" + ); + Assert.throws( + () => root.testing.callderived2({ baseprop: "s1" }), + /Property "derivedprop" is required/, + "Object using $import has its local properites" + ); + Assert.throws( + () => root.testing.callderived2({ derivedprop: 42 }), + /Property "baseprop" is required/, + "Object using $import has imported properites" + ); +}); + +let deprecatedJson = [ + { + namespace: "deprecated", + + properties: { + accessor: { + type: "string", + writable: true, + deprecated: "This is not the property you are looking for", + }, + }, + + types: [ + { + id: "Type", + type: "string", + }, + ], + + functions: [ + { + name: "property", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { + type: "string", + }, + }, + additionalProperties: { + type: "any", + deprecated: "Unknown property", + }, + }, + ], + }, + + { + name: "value", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "integer", + }, + { + type: "string", + deprecated: "Please use an integer, not ${value}", + }, + ], + }, + ], + }, + + { + name: "choices", + type: "function", + parameters: [ + { + name: "arg", + deprecated: "You have no choices", + choices: [ + { + type: "integer", + }, + ], + }, + ], + }, + + { + name: "ref", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + $ref: "Type", + deprecated: "Deprecated alias", + }, + ], + }, + ], + }, + + { + name: "method", + type: "function", + deprecated: "Do not call this method", + parameters: [], + }, + ], + + events: [ + { + name: "onDeprecated", + type: "function", + deprecated: "This event does not work", + }, + ], + }, +]; + +add_task(async function testDeprecation() { + // This whole test expects deprecation warnings. + ExtensionTestUtils.failOnSchemaWarnings(false); + + let url = "data:," + JSON.stringify(deprecatedJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + root.deprecated.property({ foo: "bar", xxx: "any", yyy: "property" }); + verify("call", "deprecated", "property", [ + { foo: "bar", xxx: "any", yyy: "property" }, + ]); + checkErrors([ + "Warning processing xxx: Unknown property", + "Warning processing yyy: Unknown property", + ]); + + root.deprecated.value(12); + verify("call", "deprecated", "value", [12]); + checkErrors([]); + + root.deprecated.value("12"); + verify("call", "deprecated", "value", ["12"]); + checkErrors(['Please use an integer, not "12"']); + + root.deprecated.choices(12); + verify("call", "deprecated", "choices", [12]); + checkErrors(["You have no choices"]); + + root.deprecated.ref("12"); + verify("call", "deprecated", "ref", ["12"]); + checkErrors(["Deprecated alias"]); + + root.deprecated.method(); + verify("call", "deprecated", "method", []); + checkErrors(["Do not call this method"]); + + void root.deprecated.accessor; + verify("get", "deprecated", "accessor", null); + checkErrors(["This is not the property you are looking for"]); + + root.deprecated.accessor = "x"; + verify("set", "deprecated", "accessor", "x"); + checkErrors(["This is not the property you are looking for"]); + + root.deprecated.onDeprecated.addListener(() => {}); + checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.removeListener(() => {}); + checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.hasListener(() => {}); + checkErrors(["This event does not work"]); + + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.throws( + () => root.deprecated.onDeprecated.hasListener(() => {}), + /This event does not work/, + "Deprecation warning with extensions.webextensions.warnings-as-errors=true" + ); +}); + +let choicesJson = [ + { + namespace: "choices", + + types: [], + + functions: [ + { + name: "meh", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "string", + enum: ["foo", "bar", "baz"], + }, + { + type: "string", + pattern: "florg.*meh", + }, + { + type: "integer", + minimum: 12, + maximum: 42, + }, + ], + }, + ], + }, + + { + name: "foo", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "object", + properties: { + blurg: { + type: "string", + unsupported: true, + optional: true, + }, + }, + additionalProperties: { + type: "string", + }, + }, + { + type: "string", + }, + { + type: "array", + minItems: 2, + maxItems: 3, + items: { + type: "integer", + }, + }, + ], + }, + ], + }, + + { + name: "bar", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "object", + properties: { + baz: { + type: "string", + }, + }, + }, + { + type: "array", + items: { + type: "integer", + }, + }, + ], + }, + ], + }, + ], + }, +]; + +add_task(async function testChoices() { + let url = "data:," + JSON.stringify(choicesJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + Assert.throws( + () => root.choices.meh("frog"), + /Value "frog" must either: be one of \["foo", "bar", "baz"\], match the pattern \/florg\.\*meh\/, or be an integer value/ + ); + + Assert.throws( + () => root.choices.meh(4), + /be a string value, or be at least 12/ + ); + + Assert.throws( + () => root.choices.meh(43), + /be a string value, or be no greater than 42/ + ); + + Assert.throws( + () => root.choices.foo([]), + /be an object value, be a string value, or have at least 2 items/ + ); + + Assert.throws( + () => root.choices.foo([1, 2, 3, 4]), + /be an object value, be a string value, or have at most 3 items/ + ); + + Assert.throws( + () => root.choices.foo({ foo: 12 }), + /.foo must be a string value, be a string value, or be an array value/ + ); + + Assert.throws( + () => root.choices.foo({ blurg: "foo" }), + /not contain an unsupported "blurg" property, be a string value, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({}), + /contain the required "baz" property, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({ baz: "x", quux: "y" }), + /not contain an unexpected "quux" property, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({ baz: "x", quux: "y", foo: "z" }), + /not contain the unexpected properties \[foo, quux\], or be an array value/ + ); +}); + +let permissionsJson = [ + { + namespace: "noPerms", + + types: [], + + functions: [ + { + name: "noPerms", + type: "function", + parameters: [], + }, + + { + name: "fooPerm", + type: "function", + permissions: ["foo"], + parameters: [], + }, + ], + }, + + { + namespace: "fooPerm", + + permissions: ["foo"], + + types: [], + + functions: [ + { + name: "noPerms", + type: "function", + parameters: [], + }, + + { + name: "fooBarPerm", + type: "function", + permissions: ["foo.bar"], + parameters: [], + }, + ], + }, +]; + +add_task(async function testPermissions() { + let url = "data:," + JSON.stringify(permissionsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + + equal( + root.noPerms.fooPerm, + undefined, + "noPerms.fooPerm should not method exist" + ); + + equal(root.fooPerm, undefined, "fooPerm namespace should not exist"); + + info('Add "foo" permission'); + permissions.add("foo"); + + root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.noPerms.fooPerm, + "function", + "noPerms.fooPerm method should exist" + ); + + equal(typeof root.fooPerm, "object", "fooPerm namespace should exist"); + equal( + typeof root.fooPerm.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + + equal( + root.fooPerm.fooBarPerm, + undefined, + "fooPerm.fooBarPerm method should not exist" + ); + + info('Add "foo.bar" permission'); + permissions.add("foo.bar"); + + root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.noPerms.fooPerm, + "function", + "noPerms.fooPerm method should exist" + ); + + equal(typeof root.fooPerm, "object", "fooPerm namespace should exist"); + equal( + typeof root.fooPerm.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.fooPerm.fooBarPerm, + "function", + "noPerms.fooBarPerm method should exist" + ); +}); + +let nestedNamespaceJson = [ + { + namespace: "nested.namespace", + types: [ + { + id: "CustomType", + type: "object", + events: [ + { + name: "onEvent", + type: "function", + }, + ], + properties: { + url: { + type: "string", + }, + }, + functions: [ + { + name: "functionOnCustomType", + type: "function", + parameters: [ + { + name: "title", + type: "string", + }, + ], + }, + ], + }, + ], + properties: { + instanceOfCustomType: { + $ref: "CustomType", + }, + }, + functions: [ + { + name: "create", + type: "function", + parameters: [ + { + name: "title", + type: "string", + }, + ], + }, + ], + }, +]; + +add_task(async function testNestedNamespace() { + let url = "data:," + JSON.stringify(nestedNamespaceJson); + + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + ok(root.nested, "The root object contains the first namespace level"); + ok( + root.nested.namespace, + "The first level object contains the second namespace level" + ); + + ok( + root.nested.namespace.create, + "Got the expected function in the nested namespace" + ); + equal( + typeof root.nested.namespace.create, + "function", + "The property is a function as expected" + ); + + let { instanceOfCustomType } = root.nested.namespace; + + ok( + instanceOfCustomType, + "Got the expected instance of the CustomType defined in the schema" + ); + ok( + instanceOfCustomType.functionOnCustomType, + "Got the expected method in the CustomType instance" + ); + ok( + instanceOfCustomType.onEvent && + instanceOfCustomType.onEvent.addListener && + typeof instanceOfCustomType.onEvent.addListener == "function", + "Got the expected event defined in the CustomType instance" + ); + + instanceOfCustomType.functionOnCustomType("param_value"); + verify( + "call", + "nested.namespace.instanceOfCustomType", + "functionOnCustomType", + ["param_value"] + ); + + let fakeListener = () => {}; + instanceOfCustomType.onEvent.addListener(fakeListener); + verify("addListener", "nested.namespace.instanceOfCustomType", "onEvent", [ + fakeListener, + [], + ]); + instanceOfCustomType.onEvent.removeListener(fakeListener); + verify("removeListener", "nested.namespace.instanceOfCustomType", "onEvent", [ + fakeListener, + ]); + + // TODO: test support properties in a SubModuleType defined in the schema, + // once implemented, e.g.: + // ok("url" in instanceOfCustomType, + // "Got the expected property defined in the CustomType instance"); +}); + +let $importJson = [ + { + namespace: "from_the", + $import: "future", + }, + { + namespace: "future", + properties: { + PROP1: { value: "original value" }, + PROP2: { value: "second original" }, + }, + types: [ + { + id: "Colour", + type: "string", + enum: ["red", "white", "blue"], + }, + ], + functions: [ + { + name: "dye", + type: "function", + parameters: [{ name: "arg", $ref: "Colour" }], + }, + ], + }, + { + namespace: "embrace", + $import: "future", + properties: { + PROP2: { value: "overridden value" }, + }, + types: [ + { + id: "Colour", + type: "string", + enum: ["blue", "orange"], + }, + ], + }, +]; + +add_task(async function test_$import() { + let url = "data:," + JSON.stringify($importJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + tallied = null; + Schemas.inject(root, wrapper); + equal(tallied, null); + + equal(root.from_the.PROP1, "original value", "imported property"); + equal(root.from_the.PROP2, "second original", "second imported property"); + equal(root.from_the.Colour.RED, "red", "imported enum type"); + equal(typeof root.from_the.dye, "function", "imported function"); + + root.from_the.dye("white"); + verify("call", "from_the", "dye", ["white"]); + + Assert.throws( + () => root.from_the.dye("orange"), + /Invalid enumeration value/, + "original imported argument type Colour doesn't include 'orange'" + ); + + equal(root.embrace.PROP1, "original value", "imported property"); + equal(root.embrace.PROP2, "overridden value", "overridden property"); + equal(root.embrace.Colour.ORANGE, "orange", "overridden enum type"); + equal(typeof root.embrace.dye, "function", "imported function"); + + root.embrace.dye("orange"); + verify("call", "embrace", "dye", ["orange"]); + + Assert.throws( + () => root.embrace.dye("white"), + /Invalid enumeration value/, + "overridden argument type Colour doesn't include 'white'" + ); +}); + +add_task(async function testLocalAPIImplementation() { + let countGet2 = 0; + let countProp3 = 0; + let countProp3SubFoo = 0; + + let testingApiObj = { + get PROP1() { + // PROP1 is a schema-defined constant. + throw new Error("Unexpected get PROP1"); + }, + get prop2() { + ++countGet2; + return "prop2 val"; + }, + get prop3() { + throw new Error("Unexpected get prop3"); + }, + set prop3(v) { + // prop3 is a submodule, defined as a function, so the API should not pass + // through assignment to prop3. + throw new Error("Unexpected set prop3"); + }, + }; + let submoduleApiObj = { + get sub_foo() { + ++countProp3; + return () => { + return ++countProp3SubFoo; + }; + }, + }; + + let localWrapper = { + cloneScope: global, + shouldInject(ns, name) { + return name == "testing" || ns == "testing" || ns == "testing.prop3"; + }, + getImplementation(ns, name) { + Assert.ok(ns == "testing" || ns == "testing.prop3"); + if (ns == "testing.prop3" && name == "sub_foo") { + // It is fine to use `null` here because we don't call async functions. + return new LocalAPIImplementation(submoduleApiObj, name, null); + } + // It is fine to use `null` here because we don't call async functions. + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + Assert.equal(countGet2, 0); + Assert.equal(countProp3, 0); + Assert.equal(countProp3SubFoo, 0); + + Assert.equal(root.testing.PROP1, 20); + + Assert.equal(root.testing.prop2, "prop2 val"); + Assert.equal(countGet2, 1); + + Assert.equal(root.testing.prop2, "prop2 val"); + Assert.equal(countGet2, 2); + + info(JSON.stringify(root.testing)); + Assert.equal(root.testing.prop3.sub_foo(), 1); + Assert.equal(countProp3, 1); + Assert.equal(countProp3SubFoo, 1); + + Assert.equal(root.testing.prop3.sub_foo(), 2); + Assert.equal(countProp3, 2); + Assert.equal(countProp3SubFoo, 2); + + root.testing.prop3.sub_foo = () => { + return "overwritten"; + }; + Assert.equal(root.testing.prop3.sub_foo(), "overwritten"); + + root.testing.prop3 = { + sub_foo() { + return "overwritten again"; + }, + }; + Assert.equal(root.testing.prop3.sub_foo(), "overwritten again"); + Assert.equal(countProp3SubFoo, 2); +}); + +let defaultsJson = [ + { + namespace: "defaultsJson", + + types: [], + + functions: [ + { + name: "defaultFoo", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + optional: true, + properties: { + prop1: { type: "integer", optional: true }, + }, + default: { prop1: 1 }, + }, + ], + returns: { + type: "object", + additionalProperties: true, + }, + }, + ], + }, +]; + +add_task(async function testDefaults() { + let url = "data:," + JSON.stringify(defaultsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let testingApiObj = { + defaultFoo: function(arg) { + if (Object.keys(arg) != "prop1") { + throw new Error( + `Received the expected default object, default: ${JSON.stringify( + arg + )}` + ); + } + arg.newProp = 1; + return arg; + }, + }; + + let localWrapper = { + cloneScope: global, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 }); + deepEqual(root.defaultsJson.defaultFoo({ prop1: 2 }), { + prop1: 2, + newProp: 1, + }); + deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 }); +}); + +let returnsJson = [ + { + namespace: "returns", + types: [ + { + id: "Widget", + type: "object", + properties: { + size: { type: "integer" }, + colour: { type: "string", optional: true }, + }, + }, + ], + functions: [ + { + name: "complete", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + { + name: "optional", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + { + name: "invalid", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + ], + }, +]; + +add_task(async function testReturns() { + const url = "data:," + JSON.stringify(returnsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + const apiObject = { + complete() { + return { size: 3, colour: "orange" }; + }, + optional() { + return { size: 4 }; + }, + invalid() { + return {}; + }, + }; + + const localWrapper = { + cloneScope: global, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(apiObject, name, null); + }, + }; + + const root = {}; + Schemas.inject(root, localWrapper); + + deepEqual(root.returns.complete(), { size: 3, colour: "orange" }); + deepEqual( + root.returns.optional(), + { size: 4 }, + "Missing optional properties is allowed" + ); + + if (AppConstants.DEBUG) { + Assert.throws( + () => root.returns.invalid(), + /Type error for result value \(Property "size" is required\)/, + "Should throw for invalid result in DEBUG builds" + ); + } else { + deepEqual( + root.returns.invalid(), + {}, + "Doesn't throw for invalid result value in release builds" + ); + } +}); + +let booleanEnumJson = [ + { + namespace: "booleanEnum", + + types: [ + { + id: "enumTrue", + type: "boolean", + enum: [true], + }, + ], + functions: [ + { + name: "paramMustBeTrue", + type: "function", + parameters: [{ name: "arg", $ref: "enumTrue" }], + }, + ], + }, +]; + +add_task(async function testBooleanEnum() { + let url = "data:," + JSON.stringify(booleanEnumJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + tallied = null; + Schemas.inject(root, wrapper); + Assert.equal(tallied, null); + + ok(root.booleanEnum, "namespace exists"); + root.booleanEnum.paramMustBeTrue(true); + verify("call", "booleanEnum", "paramMustBeTrue", [true]); + Assert.throws( + () => root.booleanEnum.paramMustBeTrue(false), + /Type error for parameter arg \(Invalid value false\) for booleanEnum\.paramMustBeTrue\./, + "should throw because enum of the type restricts parameter to true" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js new file mode 100644 index 0000000000..0c90cda51e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js @@ -0,0 +1,157 @@ +"use strict"; + +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +const global = this; + +let schemaJson = [ + { + namespace: "noAllowedContexts", + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_zero", "test_one"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_one"] }, + }, + }, + { + namespace: "defaultContexts", + defaultContexts: ["test_two"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_three"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_two"] }, + }, + }, + { + namespace: "withAllowedContexts", + allowedContexts: ["test_four"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_five"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_three"] }, + }, + }, + { + namespace: "withAllowedContextsAndDefault", + allowedContexts: ["test_six"], + defaultContexts: ["test_seven"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_eight"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_four"] }, + }, + }, + { + namespace: "with_submodule", + defaultContexts: ["test_nine"], + types: [ + { + id: "subtype", + type: "object", + functions: [ + { + name: "noAllowedContexts", + type: "function", + parameters: [], + }, + { + name: "allowedContexts", + allowedContexts: ["test_ten"], + type: "function", + parameters: [], + }, + ], + }, + ], + properties: { + prop1: { $ref: "subtype" }, + prop2: { $ref: "subtype", allowedContexts: ["test_eleven"] }, + }, + }, +]; + +add_task(async function testRestrictions() { + let url = "data:," + JSON.stringify(schemaJson); + await Schemas.load(url); + let results = {}; + let localWrapper = { + cloneScope: global, + shouldInject(ns, name, allowedContexts) { + name = ns ? ns + "." + name : name; + results[name] = allowedContexts.join(","); + return true; + }, + getImplementation() { + // The actual implementation is not significant for this test. + // Let's take this opportunity to see if schema generation is free of + // exceptions even when somehow getImplementation does not return an + // implementation. + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + function verify(path, expected) { + let obj = root; + for (let thing of path.split(".")) { + try { + obj = obj[thing]; + } catch (e) { + // Blech. + } + } + + let result = results[path]; + equal(result, expected, path); + } + + verify("noAllowedContexts", ""); + verify("noAllowedContexts.prop1", ""); + verify("noAllowedContexts.prop2", "test_zero,test_one"); + verify("noAllowedContexts.prop3", ""); + verify("noAllowedContexts.prop4", "numeric_one"); + + verify("defaultContexts", ""); + verify("defaultContexts.prop1", "test_two"); + verify("defaultContexts.prop2", "test_three"); + verify("defaultContexts.prop3", "test_two"); + verify("defaultContexts.prop4", "numeric_two"); + + verify("withAllowedContexts", "test_four"); + verify("withAllowedContexts.prop1", ""); + verify("withAllowedContexts.prop2", "test_five"); + verify("withAllowedContexts.prop3", ""); + verify("withAllowedContexts.prop4", "numeric_three"); + + verify("withAllowedContextsAndDefault", "test_six"); + verify("withAllowedContextsAndDefault.prop1", "test_seven"); + verify("withAllowedContextsAndDefault.prop2", "test_eight"); + verify("withAllowedContextsAndDefault.prop3", "test_seven"); + verify("withAllowedContextsAndDefault.prop4", "numeric_four"); + + verify("with_submodule", ""); + verify("with_submodule.prop1", "test_nine"); + verify("with_submodule.prop1.noAllowedContexts", "test_nine"); + verify("with_submodule.prop1.allowedContexts", "test_ten"); + verify("with_submodule.prop2", "test_eleven"); + // Note: test_nine inherits allowed contexts from the namespace, not from + // submodule. There is no "defaultContexts" for submodule types to not + // complicate things. + verify("with_submodule.prop1.noAllowedContexts", "test_nine"); + verify("with_submodule.prop1.allowedContexts", "test_ten"); + + // This is a constant, so it does not matter that getImplementation does not + // return an implementation since the API injector should take care of it. + equal(root.noAllowedContexts.prop3, 1); + + Assert.throws( + () => root.noAllowedContexts.prop1, + /undefined/, + "Should throw when the implementation is absent." + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js new file mode 100644 index 0000000000..0ef7b81eaf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js @@ -0,0 +1,352 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +let { BaseContext, LocalAPIImplementation } = ExtensionCommon; + +let schemaJson = [ + { + namespace: "testnamespace", + types: [ + { + id: "Widget", + type: "object", + properties: { + size: { type: "integer" }, + colour: { type: "string", optional: true }, + }, + }, + ], + functions: [ + { + name: "one_required", + type: "function", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + }, + ], + }, + { + name: "one_optional", + type: "function", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + optional: true, + }, + ], + }, + { + name: "async_required", + type: "function", + async: "first", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + }, + ], + }, + { + name: "async_optional", + type: "function", + async: "first", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + optional: true, + }, + ], + }, + { + name: "async_result", + type: "function", + async: "callback", + parameters: [ + { + name: "callback", + type: "function", + parameters: [ + { + name: "widget", + $ref: "Widget", + }, + ], + }, + ], + }, + ], + }, +]; + +const global = this; +class StubContext extends BaseContext { + constructor() { + let fakeExtension = { id: "test@web.extension" }; + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return this.sandbox; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +let context; + +function generateAPIs(extraWrapper, apiObj) { + context = new StubContext(); + let localWrapper = { + cloneScope: global, + shouldInject() { + return true; + }, + getImplementation(namespace, name) { + return new LocalAPIImplementation(apiObj, name, context); + }, + }; + Object.assign(localWrapper, extraWrapper); + + let root = {}; + Schemas.inject(root, localWrapper); + return root.testnamespace; +} + +add_task(async function testParameterValidation() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + + let testnamespace; + function assertThrows(name, ...args) { + Assert.throws( + () => testnamespace[name](...args), + /Incorrect argument types/, + `Expected testnamespace.${name}(${args.map(String).join(", ")}) to throw.` + ); + } + function assertNoThrows(name, ...args) { + try { + testnamespace[name](...args); + } catch (e) { + info( + `testnamespace.${name}(${args + .map(String) + .join(", ")}) unexpectedly threw.` + ); + throw new Error(e); + } + } + let cb = () => {}; + + for (let isChromeCompat of [true, false]) { + info(`Testing API validation with isChromeCompat=${isChromeCompat}`); + testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + one_required() {}, + one_optional() {}, + async_required() {}, + async_optional() {}, + } + ); + + assertThrows("one_required"); + assertThrows("one_required", null); + assertNoThrows("one_required", cb); + assertThrows("one_required", cb, null); + assertThrows("one_required", cb, cb); + + assertNoThrows("one_optional"); + assertNoThrows("one_optional", null); + assertNoThrows("one_optional", cb); + assertThrows("one_optional", cb, null); + assertThrows("one_optional", cb, cb); + + // Schema-based validation happens before an async method is called, so + // errors should be thrown synchronously. + + // The parameter was declared as required, but there was also an "async" + // attribute with the same value as the parameter name, so the callback + // parameter is actually optional. + assertNoThrows("async_required"); + assertNoThrows("async_required", null); + assertNoThrows("async_required", cb); + assertThrows("async_required", cb, null); + assertThrows("async_required", cb, cb); + + assertNoThrows("async_optional"); + assertNoThrows("async_optional", null); + assertNoThrows("async_optional", cb); + assertThrows("async_optional", cb, null); + assertThrows("async_optional", cb, cb); + } +}); + +add_task(async function testCheckAsyncResults() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + + const complete = generateAPIs( + {}, + { + async_result: async () => ({ size: 5, colour: "green" }), + } + ); + + const optional = generateAPIs( + {}, + { + async_result: async () => ({ size: 6 }), + } + ); + + const invalid = generateAPIs( + {}, + { + async_result: async () => ({}), + } + ); + + deepEqual(await complete.async_result(), { size: 5, colour: "green" }); + + deepEqual( + await optional.async_result(), + { size: 6 }, + "Missing optional properties is allowed" + ); + + if (AppConstants.DEBUG) { + await Assert.rejects( + invalid.async_result(), + /Type error for widget value \(Property "size" is required\)/, + "Should throw for invalid callback argument in DEBUG builds" + ); + } else { + deepEqual( + await invalid.async_result(), + {}, + "Invalid callback argument doesn't throw in release builds" + ); + } +}); + +add_task(async function testAsyncResults() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + function runWithCallback(func) { + info(`Calling testnamespace.${func.name}, expecting callback with result`); + return new Promise(resolve => { + let result = "uninitialized value"; + let returnValue = func(reply => { + result = reply; + resolve(result); + }); + // When a callback is given, the return value must be missing. + Assert.equal(returnValue, undefined); + // Callback must be called asynchronously. + Assert.equal(result, "uninitialized value"); + }); + } + + function runFailCallback(func) { + info(`Calling testnamespace.${func.name}, expecting callback with error`); + return new Promise(resolve => { + func(reply => { + Assert.equal(reply, undefined); + resolve(context.lastError.message); // eslint-disable-line no-undef + }); + }); + } + + for (let isChromeCompat of [true, false]) { + info(`Testing API invocation with isChromeCompat=${isChromeCompat}`); + let testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + async_required(cb) { + Assert.equal(cb, undefined); + return Promise.resolve(1); + }, + async_optional(cb) { + Assert.equal(cb, undefined); + return Promise.resolve(2); + }, + } + ); + if (!isChromeCompat) { + // No promises for chrome. + info("testnamespace.async_required should be a Promise"); + let promise = testnamespace.async_required(); + Assert.ok(promise instanceof context.cloneScope.Promise); + Assert.equal(await promise, 1); + + info("testnamespace.async_optional should be a Promise"); + promise = testnamespace.async_optional(); + Assert.ok(promise instanceof context.cloneScope.Promise); + Assert.equal(await promise, 2); + } + + Assert.equal(await runWithCallback(testnamespace.async_required), 1); + Assert.equal(await runWithCallback(testnamespace.async_optional), 2); + + let otherSandbox = Cu.Sandbox(null, {}); + let errorFactories = [ + msg => { + throw new context.cloneScope.Error(msg); + }, + msg => context.cloneScope.Promise.reject({ message: msg }), + msg => Cu.evalInSandbox(`throw new Error("${msg}")`, otherSandbox), + msg => + Cu.evalInSandbox(`Promise.reject({message: "${msg}"})`, otherSandbox), + ]; + for (let makeError of errorFactories) { + info(`Testing callback/promise with error caused by: ${makeError}`); + testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + async_required() { + return makeError("ONE"); + }, + async_optional() { + return makeError("TWO"); + }, + } + ); + + if (!isChromeCompat) { + // No promises for chrome. + await Assert.rejects( + testnamespace.async_required(), + /ONE/, + "should reject testnamespace.async_required()" + ); + await Assert.rejects( + testnamespace.async_optional(), + /TWO/, + "should reject testnamespace.async_optional()" + ); + } + + Assert.equal(await runFailCallback(testnamespace.async_required), "ONE"); + Assert.equal(await runFailCallback(testnamespace.async_optional), "TWO"); + } + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js new file mode 100644 index 0000000000..66de5c8aba --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js @@ -0,0 +1,174 @@ +"use strict"; + +const { ExtensionManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionChild.jsm", + null +); + +let experimentAPIs = { + userinputtest: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["userinputtest"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["userinputtest", "child"]], + }, + }, +}; + +let experimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "userinputtest", + functions: [ + { + name: "test", + type: "function", + async: true, + requireUserInput: true, + parameters: [], + }, + { + name: "child", + type: "function", + async: true, + requireUserInput: true, + parameters: [], + }, + ], + }, + ]), + + /* globals ExtensionAPI */ + "parent.js": () => { + this.userinputtest = class extends ExtensionAPI { + getAPI(context) { + return { + userinputtest: { + test() {}, + }, + }; + } + }; + }, + + "child.js": () => { + this.userinputtest = class extends ExtensionAPI { + getAPI(context) { + return { + userinputtest: { + child() {}, + }, + }; + } + }; + }, +}; + +// Set the "handlingUserInput" flag for the given extension's background page. +// Returns an RAIIHelper that should be destruct()ed eventually. +function setHandlingUserInput(extension) { + let extensionChild = ExtensionManager.extensions.get(extension.extension.id); + let bgwin = null; + for (let view of extensionChild.views) { + if (view.viewType == "background") { + bgwin = view.contentWindow; + break; + } + } + notEqual(bgwin, null, "Found background window for the test extension"); + let winutils = bgwin.windowUtils; + return winutils.setHandlingUserInput(true); +} + +// Test that the schema requireUserInput flag works correctly for +// proxied api implementations. +add_task(async function test_proxy() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + background() { + browser.test.onMessage.addListener(async () => { + try { + await browser.userinputtest.test(); + browser.test.sendMessage("result", null); + } catch (err) { + browser.test.sendMessage("result", err.message); + } + }); + }, + manifest: { + permissions: ["experiments.userinputtest"], + experiment_apis: experimentAPIs, + }, + files: experimentFiles, + }); + + await extension.startup(); + + extension.sendMessage("test"); + let result = await extension.awaitMessage("result"); + ok( + /test may only be called from a user input handler/.test(result), + `function failed when not called from a user input handler: ${result}` + ); + + let handle = setHandlingUserInput(extension); + extension.sendMessage("test"); + result = await extension.awaitMessage("result"); + equal( + result, + null, + "function succeeded when called from a user input handler" + ); + handle.destruct(); + + await extension.unload(); +}); + +// Test that the schema requireUserInput flag works correctly for +// non-proxied api implementations. +add_task(async function test_local() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + background() { + browser.test.onMessage.addListener(async () => { + try { + await browser.userinputtest.child(); + browser.test.sendMessage("result", null); + } catch (err) { + browser.test.sendMessage("result", err.message); + } + }); + }, + manifest: { + experiment_apis: experimentAPIs, + }, + files: experimentFiles, + }); + + await extension.startup(); + + extension.sendMessage("test"); + let result = await extension.awaitMessage("result"); + ok( + /child may only be called from a user input handler/.test(result), + `function failed when not called from a user input handler: ${result}` + ); + + let handle = setHandlingUserInput(extension); + extension.sendMessage("test"); + result = await extension.awaitMessage("result"); + equal( + result, + null, + "function succeeded when called from a user input handler" + ); + handle.destruct(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js new file mode 100644 index 0000000000..86ce07a5da --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js @@ -0,0 +1,174 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { ExtensionAPI } = ExtensionCommon; + +add_task(async function() { + const schema = [ + { + namespace: "manifest", + types: [ + { + $extend: "WebExtensionManifest", + properties: { + a_manifest_property: { + type: "object", + optional: true, + properties: { + nested: { + optional: true, + type: "any", + }, + }, + additionalProperties: { $ref: "UnrecognizedProperty" }, + }, + }, + }, + ], + }, + { + namespace: "testManifestPermission", + permissions: ["manifest:a_manifest_property"], + functions: [ + { + name: "testMethod", + type: "function", + async: true, + parameters: [], + permissions: ["manifest:a_manifest_property.nested"], + }, + ], + }, + ]; + + class FakeAPI extends ExtensionAPI { + getAPI(context) { + return { + testManifestPermission: { + get testProperty() { + return "value"; + }, + testMethod() { + return Promise.resolve("value"); + }, + }, + }; + } + } + + const modules = { + testNamespace: { + url: URL.createObjectURL(new Blob([FakeAPI.toString()])), + schema: `data:,${JSON.stringify(schema)}`, + scopes: ["addon_parent", "addon_child"], + paths: [["testManifestPermission"]], + }, + }; + + Services.catMan.addCategoryEntry( + "webextension-modules", + "test-manifest-permission", + `data:,${JSON.stringify(modules)}`, + false, + false + ); + + async function testExtension(extensionDef, assertFn) { + let extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await assertFn(extension); + await extension.unload(); + } + + await testExtension( + { + manifest: { + a_manifest_property: {}, + }, + background() { + // Test hasPermission method implemented in ExtensionChild.jsm. + browser.test.assertTrue( + "testManifestPermission" in browser, + "The API namespace is defined as expected" + ); + browser.test.assertEq( + undefined, + browser.testManifestPermission && + browser.testManifestPermission.testMethod, + "The property with nested manifest property permission should not be available " + ); + browser.test.notifyPass("test-extension-manifest-without-nested-prop"); + }, + }, + async extension => { + await extension.awaitFinish( + "test-extension-manifest-without-nested-prop" + ); + + // Test hasPermission method implemented in Extension.jsm. + equal( + extension.extension.hasPermission("manifest:a_manifest_property"), + true, + "Got the expected Extension's hasPermission result on existing property" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.nested" + ), + false, + "Got the expected Extension's hasPermission result on existing subproperty" + ); + } + ); + + await testExtension( + { + manifest: { + a_manifest_property: { + nested: {}, + }, + }, + background() { + // Test hasPermission method implemented in ExtensionChild.jsm. + browser.test.assertTrue( + "testManifestPermission" in browser, + "The API namespace is defined as expected" + ); + browser.test.assertEq( + "function", + browser.testManifestPermission && + typeof browser.testManifestPermission.testMethod, + "The property with nested manifest property permission should be available " + ); + browser.test.notifyPass("test-extension-manifest-with-nested-prop"); + }, + }, + async extension => { + await extension.awaitFinish("test-extension-manifest-with-nested-prop"); + + // Test hasPermission method implemented in Extension.jsm. + equal( + extension.extension.hasPermission("manifest:a_manifest_property"), + true, + "Got the expected Extension's hasPermission result on existing property" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.nested" + ), + true, + "Got the expected Extension's hasPermission result on existing subproperty" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.unexisting" + ), + false, + "Got the expected Extension's hasPermission result on non existing subproperty" + ); + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js new file mode 100644 index 0000000000..ece69a4106 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js @@ -0,0 +1,103 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +add_task(async function() { + const schema = [ + { + namespace: "privileged", + permissions: ["mozillaAddons"], + properties: { + test: { + type: "any", + }, + }, + }, + ]; + + class API extends ExtensionAPI { + getAPI(context) { + return { + privileged: { + test: "hello", + }, + }; + } + } + + const modules = { + privileged: { + url: URL.createObjectURL(new Blob([API.toString()])), + schema: `data:,${JSON.stringify(schema)}`, + scopes: ["addon_parent"], + paths: [["privileged"]], + }, + }; + + Services.catMan.addCategoryEntry( + "webextension-modules", + "test-privileged", + `data:,${JSON.stringify(modules)}`, + false, + false + ); + + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" + ); + await AddonTestUtils.promiseStartupManager(); + + // Try accessing the privileged namespace. + async function testOnce() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "privilegedapi@tests.mozilla.org" } }, + permissions: ["mozillaAddons"], + }, + background() { + browser.test.sendMessage( + "result", + browser.privileged instanceof Object + ); + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + let result = await extension.awaitMessage("result"); + await extension.unload(); + return result; + } + + AddonTestUtils.usePrivilegedSignatures = false; + let result = await testOnce(); + equal( + result, + false, + "Privileged namespace should not be accessible to a regular webextension" + ); + + AddonTestUtils.usePrivilegedSignatures = true; + result = await testOnce(); + equal( + result, + true, + "Privileged namespace should be accessible to a webextension signed with Mozilla Extensions" + ); + + await AddonTestUtils.promiseShutdownManager(); + Services.catMan.deleteCategoryEntry( + "webextension-modules", + "test-privileged", + false + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js new file mode 100644 index 0000000000..d215338dc9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js @@ -0,0 +1,507 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +let { SchemaAPIInterface } = ExtensionCommon; + +const global = this; + +let json = [ + { + namespace: "revokableNs", + + permissions: ["revokableNs"], + + properties: { + stringProp: { + type: "string", + writable: true, + }, + + revokableStringProp: { + type: "string", + permissions: ["revokableProp"], + writable: true, + }, + + submoduleProp: { + $ref: "submodule", + }, + + revokableSubmoduleProp: { + $ref: "submodule", + permissions: ["revokableProp"], + }, + }, + + types: [ + { + id: "submodule", + type: "object", + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: { type: "integer" }, + }, + ], + }, + ], + + functions: [ + { + name: "func", + type: "function", + parameters: [], + }, + + { + name: "revokableFunc", + type: "function", + parameters: [], + permissions: ["revokableFunc"], + }, + ], + + events: [ + { + name: "onEvent", + type: "function", + }, + + { + name: "onRevokableEvent", + type: "function", + permissions: ["revokableEvent"], + }, + ], + }, +]; + +let recorded = []; + +function record(...args) { + recorded.push(args); +} + +function verify(expected) { + for (let [i, rec] of expected.entries()) { + Assert.deepEqual(recorded[i], rec, `Record ${i} matches`); + } + + equal(recorded.length, expected.length, "Got expected number of records"); + + recorded.length = 0; +} + +registerCleanupFunction(() => { + equal(recorded.length, 0, "No unchecked recorded events at shutdown"); +}); + +let permissions = new Set(); + +class APIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + record(method, args) { + record(method, this.namespace, this.name, args); + } + + revoke(...args) { + this.record("revoke", args); + } + + callFunction(...args) { + this.record("callFunction", args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(...args) { + this.record("callFunctionNoReturn", args); + } + + getProperty(...args) { + this.record("getProperty", args); + } + + setProperty(...args) { + this.record("setProperty", args); + } + + addListener(...args) { + this.record("addListener", args); + } + + removeListener(...args) { + this.record("removeListener", args); + } + + hasListener(...args) { + this.record("hasListener", args); + } +} + +let context = { + cloneScope: global, + + permissionsChanged: null, + + setPermissionsChangedCallback(callback) { + this.permissionsChanged = callback; + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + isPermissionRevokable(permission) { + return permission.startsWith("revokable"); + }, + + getImplementation(namespace, name) { + return new APIImplementation(namespace, name); + }, + + shouldInject() { + return true; + }, +}; + +function ignoreError(fn) { + try { + fn(); + } catch (e) { + // Meh. + } +} + +add_task(async function() { + let url = "data:," + JSON.stringify(json); + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, context); + equal(recorded.length, 0, "No recorded events"); + + let listener = () => {}; + let captured = {}; + + function checkRecorded() { + let possible = [ + ["revokableNs", ["getProperty", "revokableNs", "stringProp", []]], + [ + "revokableProp", + ["getProperty", "revokableNs", "revokableStringProp", []], + ], + + [ + "revokableNs", + ["setProperty", "revokableNs", "stringProp", ["stringProp"]], + ], + [ + "revokableProp", + [ + "setProperty", + "revokableNs", + "revokableStringProp", + ["revokableStringProp"], + ], + ], + + ["revokableNs", ["callFunctionNoReturn", "revokableNs", "func", [[]]]], + [ + "revokableFunc", + ["callFunctionNoReturn", "revokableNs", "revokableFunc", [[]]], + ], + + [ + "revokableNs", + ["callFunction", "revokableNs.submoduleProp", "sub_foo", [[]]], + ], + [ + "revokableProp", + ["callFunction", "revokableNs.revokableSubmoduleProp", "sub_foo", [[]]], + ], + + [ + "revokableNs", + ["addListener", "revokableNs", "onEvent", [listener, []]], + ], + ["revokableNs", ["removeListener", "revokableNs", "onEvent", [listener]]], + ["revokableNs", ["hasListener", "revokableNs", "onEvent", [listener]]], + + [ + "revokableEvent", + ["addListener", "revokableNs", "onRevokableEvent", [listener, []]], + ], + [ + "revokableEvent", + ["removeListener", "revokableNs", "onRevokableEvent", [listener]], + ], + [ + "revokableEvent", + ["hasListener", "revokableNs", "onRevokableEvent", [listener]], + ], + ]; + + let expected = []; + if (permissions.has("revokableNs")) { + for (let [perm, recording] of possible) { + if (!perm || permissions.has(perm)) { + expected.push(recording); + } + } + } + + verify(expected); + } + + function check() { + info(`Check normal access (permissions: [${Array.from(permissions)}])`); + + let ns = root.revokableNs; + + void ns.stringProp; + void ns.revokableStringProp; + + ns.stringProp = "stringProp"; + ns.revokableStringProp = "revokableStringProp"; + + ns.func(); + + if (ns.revokableFunc) { + ns.revokableFunc(); + } + + ns.submoduleProp.sub_foo(); + if (ns.revokableSubmoduleProp) { + ns.revokableSubmoduleProp.sub_foo(); + } + + ns.onEvent.addListener(listener); + ns.onEvent.removeListener(listener); + ns.onEvent.hasListener(listener); + + if (ns.onRevokableEvent) { + ns.onRevokableEvent.addListener(listener); + ns.onRevokableEvent.removeListener(listener); + ns.onRevokableEvent.hasListener(listener); + } + + checkRecorded(); + } + + function capture() { + info("Capture values"); + + let ns = root.revokableNs; + + captured = { ns }; + captured.revokableStringProp = Object.getOwnPropertyDescriptor( + ns, + "revokableStringProp" + ); + + captured.revokableSubmoduleProp = ns.revokableSubmoduleProp; + if (ns.revokableSubmoduleProp) { + captured.sub_foo = ns.revokableSubmoduleProp.sub_foo; + } + + captured.revokableFunc = ns.revokableFunc; + + captured.onRevokableEvent = ns.onRevokableEvent; + if (ns.onRevokableEvent) { + captured.addListener = ns.onRevokableEvent.addListener; + captured.removeListener = ns.onRevokableEvent.removeListener; + captured.hasListener = ns.onRevokableEvent.hasListener; + } + } + + function checkCaptured() { + info( + `Check captured value access (permissions: [${Array.from(permissions)}])` + ); + + let { ns } = captured; + + void ns.stringProp; + ignoreError(() => captured.revokableStringProp.get()); + if (!permissions.has("revokableProp")) { + void ns.revokableStringProp; + } + + ns.stringProp = "stringProp"; + ignoreError(() => captured.revokableStringProp.set("revokableStringProp")); + if (!permissions.has("revokableProp")) { + ns.revokableStringProp = "revokableStringProp"; + } + + ignoreError(() => ns.func()); + ignoreError(() => captured.revokableFunc()); + if (!permissions.has("revokableFunc")) { + ignoreError(() => ns.revokableFunc()); + } + + ignoreError(() => ns.submoduleProp.sub_foo()); + + ignoreError(() => captured.sub_foo()); + if (!permissions.has("revokableProp")) { + ignoreError(() => captured.revokableSubmoduleProp.sub_foo()); + ignoreError(() => ns.revokableSubmoduleProp.sub_foo()); + } + + ignoreError(() => ns.onEvent.addListener(listener)); + ignoreError(() => ns.onEvent.removeListener(listener)); + ignoreError(() => ns.onEvent.hasListener(listener)); + + ignoreError(() => captured.addListener(listener)); + ignoreError(() => captured.removeListener(listener)); + ignoreError(() => captured.hasListener(listener)); + if (!permissions.has("revokableEvent")) { + ignoreError(() => captured.onRevokableEvent.addListener(listener)); + ignoreError(() => captured.onRevokableEvent.removeListener(listener)); + ignoreError(() => captured.onRevokableEvent.hasListener(listener)); + + ignoreError(() => ns.onRevokableEvent.addListener(listener)); + ignoreError(() => ns.onRevokableEvent.removeListener(listener)); + ignoreError(() => ns.onRevokableEvent.hasListener(listener)); + } + + checkRecorded(); + } + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableProp"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ]); + + check(); + checkCaptured(); + + permissions.delete("revokableFunc"); + context.permissionsChanged(); + verify([["revoke", "revokableNs", "revokableFunc", []]]); + + check(); + checkCaptured(); + + permissions.delete("revokableEvent"); + context.permissionsChanged(); + + verify([["revoke", "revokableNs", "onRevokableEvent", []]]); + + check(); + checkCaptured(); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "stringProp", []], + ["revoke", "revokableNs", "func", []], + ["revoke", "revokableNs.submoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onEvent", []], + ]); + + checkCaptured(); + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + context.permissionsChanged(); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableProp"); + permissions.delete("revokableFunc"); + permissions.delete("revokableEvent"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs", "revokableFunc", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + check(); + checkCaptured(); + + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + context.permissionsChanged(); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "stringProp", []], + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs", "func", []], + ["revoke", "revokableNs", "revokableFunc", []], + ["revoke", "revokableNs.submoduleProp", "sub_foo", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onEvent", []], + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + equal(root.revokableNs, undefined, "Namespace is not defined"); + checkCaptured(); +}); + +add_task(async function test_neuter() { + context.permissionsChanged = null; + + let root = {}; + Schemas.inject(root, context); + equal(recorded.length, 0, "No recorded events"); + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + + let ns = root.revokableNs; + let { submoduleProp } = ns; + + let lazyGetter = Object.getOwnPropertyDescriptor(submoduleProp, "sub_foo"); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([]); + + equal(root.revokableNs, undefined, "Should have no revokableNs"); + equal(ns.submoduleProp, undefined, "Should have no ns.submoduleProp"); + + equal(submoduleProp.sub_foo, undefined, "No sub_foo"); + lazyGetter.get.call(submoduleProp); + equal(submoduleProp.sub_foo, undefined, "No sub_foo"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js new file mode 100644 index 0000000000..ebc881a804 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js @@ -0,0 +1,242 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { SchemaRoot } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); + +let { SchemaAPIInterface } = ExtensionCommon; + +const global = this; + +let baseSchemaJSON = [ + { + namespace: "base", + + properties: { + PROP1: { value: 42 }, + }, + + types: [ + { + id: "type1", + type: "string", + enum: ["value1", "value2", "value3"], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [{ name: "arg1", $ref: "type1" }], + }, + ], + }, +]; + +let experimentFooJSON = [ + { + namespace: "experiments.foo", + types: [ + { + id: "typeFoo", + type: "string", + enum: ["foo1", "foo2", "foo3"], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [ + { name: "arg1", $ref: "typeFoo" }, + { name: "arg2", $ref: "base.type1" }, + ], + }, + ], + }, +]; + +let experimentBarJSON = [ + { + namespace: "experiments.bar", + types: [ + { + id: "typeBar", + type: "string", + enum: ["bar1", "bar2", "bar3"], + }, + ], + + functions: [ + { + name: "bar", + type: "function", + parameters: [ + { name: "arg1", $ref: "typeBar" }, + { name: "arg2", $ref: "base.type1" }, + ], + }, + ], + }, +]; + +let tallied = null; + +function tally(kind, ns, name, args) { + tallied = [kind, ns, name, args]; +} + +function verify(...args) { + equal(JSON.stringify(tallied), JSON.stringify(args)); + tallied = null; +} + +let talliedErrors = []; + +let permissions = new Set(); + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + callFunction(args) { + tally("call", this.namespace, this.name, args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(args) { + tally("call", this.namespace, this.name, args); + } + + getProperty() { + tally("get", this.namespace, this.name); + } + + setProperty(value) { + tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + tally("addListener", this.namespace, this.name, [listener, args]); + } + + removeListener(listener) { + tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + tally("hasListener", this.namespace, this.name, [listener]); + } +} + +let wrapper = { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + + cloneScope: global, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`); + }, + }, + + logError(message) { + talliedErrors.push(message); + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + shouldInject(ns, name) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(namespace, name); + }, +}; + +add_task(async function() { + let baseSchemas = new Map([["resource://schemas/base.json", baseSchemaJSON]]); + let experimentSchemas = new Map([ + ["resource://experiment-foo/schema.json", experimentFooJSON], + ["resource://experiment-bar/schema.json", experimentBarJSON], + ]); + + let baseSchema = new SchemaRoot(null, baseSchemas); + let schema = new SchemaRoot(baseSchema, experimentSchemas); + + baseSchema.parseSchemas(); + schema.parseSchemas(); + + let root = {}; + let base = {}; + + tallied = null; + + baseSchema.inject(base, wrapper); + schema.inject(root, wrapper); + + equal(typeof base.base, "object", "base.base exists"); + equal(typeof root.base, "object", "root.base exists"); + equal(typeof base.experiments, "undefined", "base.experiments exists not"); + equal(typeof root.experiments, "object", "root.experiments exists"); + equal(typeof root.experiments.foo, "object", "root.experiments.foo exists"); + equal(typeof root.experiments.bar, "object", "root.experiments.bar exists"); + + equal(tallied, null); + + equal(root.base.PROP1, 42, "root.base.PROP1"); + equal(base.base.PROP1, 42, "root.base.PROP1"); + + root.base.foo("value2"); + verify("call", "base", "foo", ["value2"]); + + base.base.foo("value3"); + verify("call", "base", "foo", ["value3"]); + + root.experiments.foo.foo("foo2", "value1"); + verify("call", "experiments.foo", "foo", ["foo2", "value1"]); + + root.experiments.bar.bar("bar2", "value1"); + verify("call", "experiments.bar", "bar", ["bar2", "value1"]); + + Assert.throws( + () => root.base.foo("Meh."), + /Type error for parameter arg1/, + "root.base.foo()" + ); + + Assert.throws( + () => base.base.foo("Meh."), + /Type error for parameter arg1/, + "base.base.foo()" + ); + + Assert.throws( + () => root.experiments.foo.foo("Meh."), + /Incorrect argument types/, + "root.experiments.foo.foo()" + ); + + Assert.throws( + () => root.experiments.bar.bar("Meh."), + /Incorrect argument types/, + "root.experiments.bar.bar()" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js new file mode 100644 index 0000000000..626d8de22d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js @@ -0,0 +1,59 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_shadowDOM() { + function backgroundScript() { + browser.test.assertTrue( + "openOrClosedShadowRoot" in document.documentElement, + "Should have openOrClosedShadowRoot in Element in background script." + ); + } + + function contentScript() { + let host = document.getElementById("host"); + browser.test.assertTrue( + "openOrClosedShadowRoot" in host, + "Should have openOrClosedShadowRoot in Element." + ); + let shadowRoot = host.openOrClosedShadowRoot; + browser.test.assertEq( + shadowRoot.mode, + "closed", + "Should have closed ShadowRoot." + ); + browser.test.sendMessage("contentScript"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_shadowdom.html"], + js: ["content_script.js"], + }, + ], + }, + background: backgroundScript, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_shadowdom.html` + ); + await extension.awaitMessage("contentScript"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js new file mode 100644 index 0000000000..3952cefb07 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test attemps to verify that: +// - SharedWorkers can be created and successfully spawned by web extensions +// when web-extensions run in their own child process. +add_task(async function test_spawn_shared_worker() { + if (!WebExtensionPolicy.useRemoteWebExtensions) { + // Ensure RemoteWorkerService has been initialized in the main + // process. + Services.obs.notifyObservers(null, "profile-after-change"); + } + + const background = async function() { + const worker = new SharedWorker("worker.js"); + await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage("bgpage->worker"); + }); + browser.test.sendMessage("test-shared-worker:done"); + }; + + const extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "worker.js": function() { + self.onconnect = evt => { + const port = evt.ports[0]; + port.onmessage = () => port.postMessage("worker-reply"); + }; + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-shared-worker:done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js new file mode 100644 index 0000000000..8221219a38 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js @@ -0,0 +1,42 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { GlobalManager } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null +); + +add_task(async function test_global_manager_shutdown_cleanup() { + equal( + GlobalManager.initialized, + false, + "GlobalManager start as not initialized" + ); + + function background() { + browser.test.notifyPass("background page loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("background page loaded"); + + equal( + GlobalManager.initialized, + true, + "GlobalManager has been initialized once an extension is started" + ); + + await extension.unload(); + + equal( + GlobalManager.initialized, + false, + "GlobalManager has been uninitialized once all the webextensions have been stopped" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_simple.js b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js new file mode 100644 index 0000000000..7fd75eb088 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js @@ -0,0 +1,111 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_simple() { + let extensionData = { + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.unload(); +}); + +add_task(async function test_manifest_V3_disabled() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", false); + let extensionData = { + manifest: { + manifest_version: 3, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects( + extension.startup(), + /Unsupported manifest version: 3/, + "manifest V3 cannot be loaded" + ); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_manifest_V3_enabled() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let extensionData = { + manifest: { + manifest_version: 3, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + equal(extension.extension.manifest.manifest_version, 3, "manifest V3 loads"); + await extension.unload(); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_background() { + function background() { + browser.test.log("running background script"); + + browser.test.onMessage.addListener((x, y) => { + browser.test.assertEq(x, 10, "x is 10"); + browser.test.assertEq(y, 20, "y is 20"); + + browser.test.notifyPass("background test passed"); + }); + + browser.test.sendMessage("running", 1); + } + + let extensionData = { + background, + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let [, x] = await Promise.all([ + extension.startup(), + extension.awaitMessage("running"), + ]); + equal(x, 1, "got correct value from extension"); + + extension.sendMessage(10, 20); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_extensionTypes() { + let extensionData = { + background: function() { + browser.test.assertEq( + typeof browser.extensionTypes, + "object", + "browser.extensionTypes exists" + ); + browser.test.assertEq( + typeof browser.extensionTypes.RunAt, + "object", + "browser.extensionTypes.RunAt exists" + ); + browser.test.notifyPass("extentionTypes test passed"); + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js new file mode 100644 index 0000000000..df51fa9abf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js @@ -0,0 +1,55 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1" +); + +// Tests that startupData is persisted and is available at startup +add_task(async function test_startupData() { + await AddonTestUtils.promiseStartupManager(); + + let wrapper = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + }); + await wrapper.startup(); + + let { extension } = wrapper; + + deepEqual( + extension.startupData, + {}, + "startupData for a new extension defaults to empty object" + ); + + const DATA = { test: "i am some startup data" }; + extension.startupData = DATA; + extension.saveStartupData(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + ({ extension } = wrapper); + deepEqual(extension.startupData, DATA, "startupData is present on restart"); + + const DATA2 = { other: "this is different data" }; + extension.startupData = DATA2; + extension.saveStartupData(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + ({ extension } = wrapper); + deepEqual( + extension.startupData, + DATA2, + "updated startupData is present on restart" + ); + + await wrapper.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js new file mode 100644 index 0000000000..c21458e5a1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js @@ -0,0 +1,172 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +const ADDON_ID = "test-startup-cache@xpcshell.mozilla.org"; + +function makeExtension(opts) { + return { + useAddonManager: "permanent", + + manifest: { + version: opts.version, + applications: { gecko: { id: ADDON_ID } }, + + name: "__MSG_name__", + + default_locale: "en_US", + }, + + files: { + "_locales/en_US/messages.json": { + name: { + message: `en-US ${opts.version}`, + description: "Name.", + }, + }, + "_locales/fr/messages.json": { + name: { + message: `fr ${opts.version}`, + description: "Name.", + }, + }, + }, + + background() { + browser.test.onMessage.addListener(msg => { + if (msg === "get-manifest") { + browser.test.sendMessage("manifest", browser.runtime.getManifest()); + } + }); + }, + }; +} + +add_task(async function() { + Preferences.set("extensions.logging.enabled", false); + await AddonTestUtils.promiseStartupManager(); + + // Install langpacks to get proper locale startup. + let langpack = { + "manifest.json": { + name: "test Language Pack", + version: "1.0", + manifest_version: 2, + applications: { + gecko: { + id: "@test-langpack", + strict_min_version: "42.0", + strict_max_version: "42.0", + }, + }, + langpack_id: "fr", + languages: { + fr: { + chrome_resources: { + global: "chrome/fr/locale/fr/global/", + }, + version: "20171001190118", + }, + }, + sources: { + browser: { + base_path: "browser/", + }, + }, + }, + }; + + let [, { addon }] = await Promise.all([ + TestUtils.topicObserved("webextension-langpack-startup"), + AddonTestUtils.promiseInstallXPI(langpack), + ]); + + let extension = ExtensionTestUtils.loadExtension( + makeExtension({ version: "1.0" }) + ); + + function getManifest() { + extension.sendMessage("get-manifest"); + return extension.awaitMessage("manifest"); + } + + // At the moment extension language negotiation is tied to Firefox language + // negotiation result. That means that to test an extension in `fr`, we need + // to mock `fr` being available in Firefox and then request it. + // + // In the future, we should provide some way for tests to decouple their + // language selection from that of Firefox. + ok(Services.locale.availableLocales.includes("fr"), "fr locale is avialable"); + + await extension.startup(); + + equal(extension.version, "1.0", "Expected extension version"); + let manifest = await getManifest(); + equal(manifest.name, "en-US 1.0", "Got expected manifest name"); + + info("Restart and re-check"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.0", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.0", "Got expected manifest name"); + + info("Change locale to 'fr' and restart"); + Services.locale.requestedLocales = ["fr"]; + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.0", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "fr 1.0", "Got expected manifest name"); + + info("Update to version 1.1"); + await extension.upgrade(makeExtension({ version: "1.1" })); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "fr 1.1", "Got expected manifest name"); + + info("Change locale to 'en-US' and restart"); + Services.locale.requestedLocales = ["en-US"]; + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.1", "Got expected manifest name"); + + info("uninstall locale 'fr'"); + addon = await AddonManager.getAddonByID("@test-langpack"); + await addon.uninstall(); + ok(!Services.locale.availableLocales.includes("fr"), "fr locale is removed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js new file mode 100644 index 0000000000..691232479d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js @@ -0,0 +1,73 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const STARTUP_APIS = ["backgroundPage"]; + +const STARTUP_MODULES = [ + "resource://gre/modules/Extension.jsm", + "resource://gre/modules/ExtensionCommon.jsm", + "resource://gre/modules/ExtensionParent.jsm", + // FIXME: This is only loaded at startup for new extension installs. + // Otherwise the data comes from the startup cache. We should test for + // this. + "resource://gre/modules/ExtensionPermissions.jsm", + "resource://gre/modules/ExtensionProcessScript.jsm", + "resource://gre/modules/ExtensionUtils.jsm", + "resource://gre/modules/ExtensionTelemetry.jsm", +]; + +if (!Services.prefs.getBoolPref("extensions.webextensions.remote")) { + STARTUP_MODULES.push( + "resource://gre/modules/ExtensionChild.jsm", + "resource://gre/modules/ExtensionPageChild.jsm" + ); +} + +if (AppConstants.MOZ_APP_NAME == "thunderbird") { + STARTUP_MODULES.push( + "resource://gre/modules/ExtensionChild.jsm", + "resource://gre/modules/ExtensionContent.jsm", + "resource://gre/modules/ExtensionPageChild.jsm" + ); +} + +AddonTestUtils.init(this); + +// Tests that only the minimal set of API scripts and modules are loaded at +// startup for a simple extension. +add_task(async function test_loaded_scripts() { + await ExtensionTestUtils.startAddonManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background() {}, + manifest: {}, + }); + + await extension.startup(); + + const { apiManager } = ExtensionParent; + + const loadedAPIs = Array.from(apiManager.modules.values()) + .filter(m => m.loaded || m.asyncLoaded) + .map(m => m.namespaceName); + + deepEqual( + loadedAPIs.sort(), + STARTUP_APIS, + "No extra APIs should be loaded at startup for a simple extension" + ); + + let loadedModules = Cu.loadedModules.filter(url => + url.startsWith("resource://gre/modules/Extension") + ); + + deepEqual( + loadedModules.sort(), + STARTUP_MODULES.sort(), + "No extra extension modules should be loaded at startup for a simple extension" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js new file mode 100644 index 0000000000..5ebe4c5230 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js @@ -0,0 +1,64 @@ +"use strict"; + +function delay(time) { + return new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, time); + }); +} + +const { Extension } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" +); + +add_task(async function test_startup_request_handler() { + const ID = "request-startup@xpcshell.mozilla.org"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: ID } }, + }, + + files: { + "meh.txt": "Meh.", + }, + }); + + let ready = false; + let resolvePromise; + let promise = new Promise(resolve => { + resolvePromise = resolve; + }); + promise.then(() => { + ready = true; + }); + + let origInitLocale = Extension.prototype.initLocale; + Extension.prototype.initLocale = async function initLocale() { + await promise; + return origInitLocale.call(this); + }; + + let startupPromise = extension.startup(); + + await delay(0); + let policy = WebExtensionPolicy.getByID(ID); + let url = policy.getURL("meh.txt"); + + let resp = ExtensionTestUtils.fetch(url, url); + resp.then(() => { + ok(ready, "Shouldn't get response before extension is ready"); + }); + + await delay(2000); + + resolvePromise(); + await startupPromise; + + let body = await resp; + equal(body, "Meh.", "Got the correct response"); + + await extension.unload(); + + Extension.prototype.initLocale = origInitLocale; +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js new file mode 100644 index 0000000000..b677110a47 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js @@ -0,0 +1,39 @@ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageIDB.jsm" +); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_local_file_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () => + test_contentscript_storage("local") + ); +}); + +add_task(async function test_contentscript_storage_local_idb_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_contentscript_storage("local") + ); +}); + +add_task(async function test_contentscript_storage_local_idb_no_bytes_in_use() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_contentscript_storage_area_no_bytes_in_use("local") + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js new file mode 100644 index 0000000000..6b1695417d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js @@ -0,0 +1,31 @@ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage("sync") + ); +}); + +add_task(async function test_contentscript_bytes_in_use_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage_area_with_bytes_in_use("sync", true) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js new file mode 100644 index 0000000000..92ec405520 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js @@ -0,0 +1,31 @@ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage("sync") + ); +}); + +add_task(async function test_contentscript_storage_no_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage_area_with_bytes_in_use("sync", false) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js new file mode 100644 index 0000000000..90d4740bf9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js @@ -0,0 +1,787 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test file verifies various scenarios related to the data migration +// from the JSONFile backend to the IDB backend. + +AddonTestUtils.init(this); + +// Create appInfo before importing any other jsm file, to prevent +// Services.appinfo to be cached before an appInfo.version is +// actually defined (which prevent failures to be triggered when +// the test run in a non nightly build). +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { getTrimmedString } = ChromeUtils.import( + "resource://gre/modules/ExtensionTelemetry.jsm" +); +const { ExtensionStorage } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorage.jsm" +); +const { ExtensionStorageIDB } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageIDB.jsm" +); +const { TelemetryController } = ChromeUtils.import( + "resource://gre/modules/TelemetryController.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + OS: "resource://gre/modules/osfile.jsm", +}); + +const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils; + +const { + IDB_MIGRATED_PREF_BRANCH, + IDB_MIGRATE_RESULT_HISTOGRAM, +} = ExtensionStorageIDB; +const CATEGORIES = ["success", "failure"]; +const EVENT_CATEGORY = "extensions.data"; +const EVENT_OBJECT = "storageLocal"; +const EVENT_METHOD = "migrateResult"; +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; +const TELEMETRY_EVENTS_FILTER = { + category: "extensions.data", + method: "migrateResult", + object: "storageLocal", +}; + +async function createExtensionJSONFileWithData(extensionId, data) { + await ExtensionStorage.set(extensionId, data); + const jsonFile = await ExtensionStorage.getFile(extensionId); + await jsonFile._save(); + const oldStorageFilename = ExtensionStorage.getStorageFile(extensionId); + equal( + await OS.File.exists(oldStorageFilename), + true, + "The old json file has been created" + ); + + return { jsonFile, oldStorageFilename }; +} + +function clearMigrationHistogram() { + const histogram = Services.telemetry.getHistogramById( + IDB_MIGRATE_RESULT_HISTOGRAM + ); + histogram.clear(); + equal( + histogram.snapshot().sum, + 0, + `No data recorded for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}` + ); +} + +function assertMigrationHistogramCount(category, expectedCount) { + const histogram = Services.telemetry.getHistogramById( + IDB_MIGRATE_RESULT_HISTOGRAM + ); + + equal( + histogram.snapshot().values[CATEGORIES.indexOf(category)], + expectedCount, + `Got the expected count on category "${category}" for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}` + ); +} + +function assertTelemetryEvents(expectedEvents) { + TelemetryTestUtils.assertEvents(expectedEvents, { + category: EVENT_CATEGORY, + method: EVENT_METHOD, + object: EVENT_OBJECT, + }); +} + +add_task(async function setup() { + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true); + + await promiseStartupManager(); + + // Telemetry test setup needed to ensure that the builtin events are defined + // and they can be collected and verified. + await TelemetryController.testSetup(); + + // This is actually only needed on Android, because it does not properly support unified telemetry + // and so, if not enabled explicitly here, it would make these tests to fail when running on a + // non-Nightly build. + const oldCanRecordBase = Services.telemetry.canRecordBase; + Services.telemetry.canRecordBase = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordBase = oldCanRecordBase; + }); + + // Clear any telemetry events collected so far. + Services.telemetry.clearEvents(); +}); + +// Test that for newly installed extension the IDB backend is enabled without +// any data migration. +add_task(async function test_no_migration_for_newly_installed_extensions() { + const EXTENSION_ID = "test-no-data-migration@mochi.test"; + + await createExtensionJSONFileWithData(EXTENSION_ID, { + test_old_data: "test_old_value", + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXTENSION_ID } }, + }, + async background() { + const data = await browser.storage.local.get(); + browser.test.assertEq( + Object.keys(data).length, + 0, + "Expect the storage.local store to be empty" + ); + browser.test.sendMessage("test-stored-data:done"); + }, + }); + + await extension.startup(); + equal( + ExtensionStorageIDB.isMigratedExtension(extension), + true, + "The newly installed test extension is marked as migrated" + ); + await extension.awaitMessage("test-stored-data:done"); + await extension.unload(); + + // Verify that no data migration have been needed on the newly installed + // extension, by asserting that no telemetry events has been collected. + await TelemetryTestUtils.assertEvents([], TELEMETRY_EVENTS_FILTER); +}); + +// Test that the data migration is still running for a newly installed extension +// if keepStorageOnUninstall is true. +add_task(async function test_data_migration_on_keep_storage_on_uninstall() { + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + + // Store some fake data in the storage.local file backend before starting the extension. + const EXTENSION_ID = "new-extension-on-keep-storage-on-uninstall@mochi.test"; + await createExtensionJSONFileWithData(EXTENSION_ID, { + test_key_string: "test_value", + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXTENSION_ID } }, + }, + async background() { + const storedData = await browser.storage.local.get(); + browser.test.assertEq( + "test_value", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + browser.test.sendMessage("storage-local-data-migrated"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("storage-local-data-migrated"); + equal( + ExtensionStorageIDB.isMigratedExtension(extension), + true, + "The newly installed test extension is marked as migrated" + ); + await extension.unload(); + + // Verify that the expected telemetry has been recorded. + await TelemetryTestUtils.assertEvents( + [ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ], + TELEMETRY_EVENTS_FILTER + ); + + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); +}); + +// Test that the old data is migrated successfully to the new storage backend +// and that the original JSONFile has been renamed. +add_task(async function test_storage_local_data_migration() { + const EXTENSION_ID = "extension-to-be-migrated@mozilla.org"; + + // Keep the extension storage and the uuid on uninstall, to verify that no telemetry events + // are being sent for an already migrated extension. + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + + const data = { + test_key_string: "test_value1", + test_key_number: 1000, + test_nested_data: { + nested_key: true, + }, + }; + + // Store some fake data in the storage.local file backend before starting the extension. + const { oldStorageFilename } = await createExtensionJSONFileWithData( + EXTENSION_ID, + data + ); + + async function background() { + const storedData = await browser.storage.local.get(); + + browser.test.assertEq( + "test_value1", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + browser.test.assertEq( + 1000, + storedData.test_key_number, + "Got the expected data after the storage.local data migration" + ); + browser.test.assertEq( + true, + storedData.test_nested_data.nested_key, + "Got the expected data after the storage.local data migration" + ); + + browser.test.sendMessage("storage-local-data-migrated"); + } + + clearMigrationHistogram(); + + let extensionDefinition = { + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionDefinition); + + // Install the extension while the storage.local IDB backend is disabled. + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, false); + await extension.startup(); + + ok( + !ExtensionStorageIDB.isMigratedExtension(extension), + "The test extension should be using the JSONFile backend" + ); + + // Enabled the storage.local IDB backend and upgrade the extension. + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true); + await extension.upgrade({ + ...extensionDefinition, + background, + }); + + await extension.awaitMessage("storage-local-data-migrated"); + + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The test extension should be using the IndexedDB backend" + ); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + + equal( + await idbConn.isEmpty(extension.extension), + false, + "Data stored in the ExtensionStorageIDB backend as expected" + ); + + equal( + await OS.File.exists(oldStorageFilename), + false, + "The old json storage file name should not exist anymore" + ); + + equal( + await OS.File.exists(`${oldStorageFilename}.migrated`), + true, + "The old json storage file name should have been renamed as .migrated" + ); + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected` + ); + + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `${IDB_MIGRATED_PREF_BRANCH} should still be true on keepStorageOnUninstall=true` + ); + + // Upgrade the extension and check that no telemetry events are being sent + // for an already migrated extension. + await extension.upgrade({ + ...extensionDefinition, + background, + }); + + await extension.awaitMessage("storage-local-data-migrated"); + + // The histogram values are unmodified. + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + // No new telemetry events recorded for the extension. + const snapshot = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ); + const filterByCategory = ([timestamp, category]) => + category === EVENT_CATEGORY; + + ok( + !snapshot.parent || snapshot.parent.filter(filterByCategory).length === 0, + "No telemetry events should be recorded for an already migrated extension" + ); + + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, false); + + await extension.unload(); + + equal( + Services.prefs.getPrefType(`${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`), + Services.prefs.PREF_INVALID, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference has been cleared on addon uninstall` + ); +}); + +// Test that the extensionId included in the telemetry event is being trimmed down to 80 chars +// as expected. +add_task(async function test_extensionId_trimmed_in_telemetry_event() { + // Generated extensionId in email-like format, longer than 80 chars. + const EXTENSION_ID = `long.extension.id@${Array(80) + .fill("a") + .join("")}`; + + const data = { test_key_string: "test_value" }; + + // Store some fake data in the storage.local file backend before starting the extension. + await createExtensionJSONFileWithData(EXTENSION_ID, data); + + async function background() { + const storedData = await browser.storage.local.get("test_key_string"); + + browser.test.assertEq( + "test_value", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + + browser.test.sendMessage("storage-local-data-migrated"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated"); + + const expectedTrimmedExtensionId = getTrimmedString(EXTENSION_ID); + + equal( + expectedTrimmedExtensionId.length, + 80, + "The trimmed version of the extensionId should be 80 chars long" + ); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: expectedTrimmedExtensionId, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + await extension.unload(); +}); + +// Test that if the old JSONFile data file is corrupted and the old data +// can't be successfully migrated to the new storage backend, then: +// - the new storage backend for that extension is still initialized and enabled +// - any new data is being stored in the new backend +// - the old file is being renamed (with the `.corrupted` suffix that JSONFile.jsm +// adds when it fails to load the data file) and still available on disk. +add_task(async function test_storage_local_corrupted_data_migration() { + const EXTENSION_ID = "extension-corrupted-data-migration@mozilla.org"; + + const invalidData = `{"test_key_string": "test_value1"`; + const oldStorageFilename = ExtensionStorage.getStorageFile(EXTENSION_ID); + + const profileDir = OS.Constants.Path.profileDir; + await OS.File.makeDir( + OS.Path.join(profileDir, "browser-extension-data", EXTENSION_ID), + { from: profileDir, ignoreExisting: true } + ); + + // Write the json file with some invalid data. + await OS.File.writeAtomic(oldStorageFilename, invalidData, { flush: true }); + equal( + await OS.File.read(oldStorageFilename, { encoding: "utf-8" }), + invalidData, + "The old json file has been overwritten with invalid data" + ); + + async function background() { + const storedData = await browser.storage.local.get(); + + browser.test.assertEq( + Object.keys(storedData).length, + 0, + "No data should be found on invalid data migration" + ); + + await browser.storage.local.set({ + test_key_string_on_IDBBackend: "expected-value", + }); + + browser.test.sendMessage("storage-local-data-migrated-and-set"); + } + + clearMigrationHistogram(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated-and-set"); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + + equal( + await idbConn.isEmpty(extension.extension), + false, + "Data stored in the ExtensionStorageIDB backend as expected" + ); + + equal( + await OS.File.exists(`${oldStorageFilename}.corrupt`), + true, + "The old json storage should still be available if failed to be read" + ); + + // The extension is still migrated successfully to the new backend if the file from the + // original json file was corrupted. + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected` + ); + + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "n", + }, + }, + ]); + + await extension.unload(); +}); + +// Test that if the data migration fails to store the old data into the IndexedDB backend +// then the expected telemetry histogram is being updated. +add_task(async function test_storage_local_data_migration_failure() { + const EXTENSION_ID = "extension-data-migration-failure@mozilla.org"; + + // Create the file under the expected directory tree. + const { + jsonFile, + oldStorageFilename, + } = await createExtensionJSONFileWithData(EXTENSION_ID, {}); + + // Store a fake invalid value which is going to fail to be saved into IndexedDB + // (because it can't be cloned and it is going to raise a DataCloneError), which + // will trigger a data migration failure that we expect to increment the related + // telemetry histogram. + jsonFile.data.set("fake_invalid_key", new Error()); + + async function background() { + await browser.storage.local.set({ + test_key_string_on_JSONFileBackend: "expected-value", + }); + browser.test.sendMessage("storage-local-data-migrated-and-set"); + } + + clearMigrationHistogram(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated-and-set"); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + equal( + await idbConn.isEmpty(extension.extension), + true, + "No data stored in the ExtensionStorageIDB backend as expected" + ); + equal( + await OS.File.exists(oldStorageFilename), + true, + "The old json storage should still be available if failed to be read" + ); + + await extension.unload(); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "JSONFile", + data_migrated: "n", + error_name: "DataCloneError", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + assertMigrationHistogramCount("success", 0); + assertMigrationHistogramCount("failure", 1); +}); + +add_task(async function test_migration_aborted_on_shutdown() { + const EXTENSION_ID = "test-migration-aborted-on-shutdown@mochi.test"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }); + + await extension.startup(); + + equal( + extension.extension.hasShutdown, + false, + "The extension is still running" + ); + + await extension.unload(); + equal(extension.extension.hasShutdown, true, "The extension has shutdown"); + + // Trigger a data migration after the extension has been unloaded. + const result = await ExtensionStorageIDB.selectBackend({ + extension: extension.extension, + }); + Assert.deepEqual( + result, + { backendEnabled: false }, + "Expect migration to have been aborted" + ); + TelemetryTestUtils.assertEvents( + [ + { + value: EXTENSION_ID, + extra: { + backend: "JSONFile", + error_name: "DataMigrationAbortedError", + }, + }, + ], + TELEMETRY_EVENTS_FILTER + ); +}); + +add_task(async function test_storage_local_data_migration_clear_pref() { + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); + Services.prefs.clearUserPref(LEAVE_UUID_PREF); + Services.prefs.clearUserPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF); + await promiseShutdownManager(); + await TelemetryController.testShutdown(); +}); + +add_task(async function setup_quota_manager_testing_prefs() { + Services.prefs.setBoolPref("dom.quotaManager.testing", true); + Services.prefs.setIntPref( + "dom.quotaManager.temporaryStorage.fixedLimit", + 100 + ); + await promiseQuotaManagerServiceReset(); +}); + +add_task( + // TODO: temporarily disabled because it currently perma-fails on + // android builds (Bug 1564871) + { skip_if: () => AppConstants.platform === "android" }, + // eslint-disable-next-line no-use-before-define + test_quota_exceeded_while_migrating_data +); +async function test_quota_exceeded_while_migrating_data() { + const EXT_ID = "test-data-migration-stuck@mochi.test"; + const dataSize = 1000 * 1024; + + await createExtensionJSONFileWithData(EXT_ID, { + data: new Array(dataSize).fill("x").join(""), + }); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXT_ID } }, + }, + background() { + browser.test.onMessage.addListener(async (msg, dataSize) => { + if (msg !== "verify-stored-data") { + return; + } + const res = await browser.storage.local.get(); + browser.test.assertEq( + res.data && res.data.length, + dataSize, + "Got the expected data" + ); + browser.test.sendMessage("verify-stored-data:done"); + }); + + browser.test.sendMessage("bg-page:ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-page:ready"); + + extension.sendMessage("verify-stored-data", dataSize); + await extension.awaitMessage("verify-stored-data:done"); + + await ok( + !ExtensionStorageIDB.isMigratedExtension(extension), + "The extension falls back to the JSONFile backend because of the migration failure" + ); + await extension.unload(); + + TelemetryTestUtils.assertEvents( + [ + { + value: EXT_ID, + extra: { + backend: "JSONFile", + error_name: "QuotaExceededError", + }, + }, + ], + TELEMETRY_EVENTS_FILTER + ); + + Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit"); + await promiseQuotaManagerServiceClear(); + Services.prefs.clearUserPref("dom.quotaManager.testing"); +} diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js new file mode 100644 index 0000000000..a74528db7d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js @@ -0,0 +1,73 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionStorageIDB", + "resource://gre/modules/ExtensionStorageIDB.jsm" +); + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_local_cache_invalidation() { + function background(checkGet) { + browser.test.onMessage.addListener(async msg => { + if (msg === "set-initial") { + await browser.storage.local.set({ + "test-prop1": "value1", + "test-prop2": "value2", + }); + browser.test.sendMessage("set-initial-done"); + } else if (msg === "check") { + await checkGet("local", "test-prop1", "value1"); + await checkGet("local", "test-prop2", "value2"); + browser.test.sendMessage("check-done"); + } + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})(${checkGetImpl})`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("set-initial"); + await extension.awaitMessage("set-initial-done"); + + Services.obs.notifyObservers(null, "extension-invalidate-storage-cache"); + + extension.sendMessage("check"); + await extension.awaitMessage("check-done"); + + await extension.unload(); +}); + +add_task(function test_storage_local_file_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () => + test_background_page_storage("local") + ); +}); + +add_task(function test_storage_local_idb_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_background_page_storage("local") + ); +}); + +add_task(function test_storage_local_idb_bytes_in_use() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_background_storage_area_no_bytes_in_use("local") + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js new file mode 100644 index 0000000000..b35e4240c4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js @@ -0,0 +1,170 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.jsm", + OS: "resource://gre/modules/osfile.jsm", +}); + +const MANIFEST = { + name: "test-storage-managed@mozilla.com", + description: "", + type: "storage", + data: { + null: null, + str: "hello", + obj: { + a: [2, 3], + b: true, + }, + }, +}; + +AddonTestUtils.init(this); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); + + let tmpDir = FileUtils.getDir("TmpD", ["native-manifests"]); + tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let dirProvider = { + getFile(property) { + if (property.endsWith("NativeManifests")) { + return tmpDir.clone(); + } + }, + }; + Services.dirsvc.registerProvider(dirProvider); + + let typeSlug = + AppConstants.platform === "linux" ? "managed-storage" : "ManagedStorage"; + OS.File.makeDir(OS.Path.join(tmpDir.path, typeSlug)); + + let path = OS.Path.join(tmpDir.path, typeSlug, `${MANIFEST.name}.json`); + await OS.File.writeAtomic(path, JSON.stringify(MANIFEST)); + + let registry; + if (AppConstants.platform === "win") { + registry = new MockRegistry(); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `Software\\\Mozilla\\\ManagedStorage\\${MANIFEST.name}`, + "", + path + ); + } + + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + tmpDir.remove(true); + if (registry) { + registry.shutdown(); + } + }); +}); + +add_task(async function test_storage_managed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: MANIFEST.name } }, + permissions: ["storage"], + }, + + async background() { + await browser.test.assertRejects( + browser.storage.managed.set({ a: 1 }), + /storage.managed is read-only/, + "browser.storage.managed.set() rejects because it's read only" + ); + + await browser.test.assertRejects( + browser.storage.managed.remove("str"), + /storage.managed is read-only/, + "browser.storage.managed.remove() rejects because it's read only" + ); + + await browser.test.assertRejects( + browser.storage.managed.clear(), + /storage.managed is read-only/, + "browser.storage.managed.clear() rejects because it's read only" + ); + + browser.test.sendMessage( + "results", + await Promise.all([ + browser.storage.managed.get(), + browser.storage.managed.get("str"), + browser.storage.managed.get(["null", "obj"]), + browser.storage.managed.get({ str: "a", num: 2 }), + ]) + ); + }, + }); + + await extension.startup(); + deepEqual(await extension.awaitMessage("results"), [ + MANIFEST.data, + { str: "hello" }, + { null: null, obj: MANIFEST.data.obj }, + { str: "hello", num: 2 }, + ]); + await extension.unload(); +}); + +add_task(async function test_storage_managed_from_content_script() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: MANIFEST.name } }, + permissions: ["storage"], + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["*://*/*"], + run_at: "document_end", + }, + ], + }, + + files: { + "contentscript.js": async function() { + browser.test.sendMessage( + "results", + await browser.storage.managed.get() + ); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + deepEqual(await extension.awaitMessage("results"), MANIFEST.data); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_manifest_not_found() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + + async background() { + await browser.test.assertRejects( + browser.storage.managed.get({ a: 1 }), + /Managed storage manifest not found/, + "browser.storage.managed.get() rejects when without manifest" + ); + + browser.test.notifyPass(); + }, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js new file mode 100644 index 0000000000..d99956671d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js @@ -0,0 +1,55 @@ +"use strict"; + +const PREF_DISABLE_SECURITY = + "security.turn_off_all_security_so_that_" + + "viruses_can_take_over_this_computer"; + +const { EnterprisePolicyTesting } = ChromeUtils.import( + "resource://testing-common/EnterprisePolicyTesting.jsm" +); + +// Setting PREF_DISABLE_SECURITY tells the policy engine that we are in testing +// mode and enables restarting the policy engine without restarting the browser. +Services.prefs.setBoolPref(PREF_DISABLE_SECURITY, true); +registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_DISABLE_SECURITY); +}); + +// Load policy engine +Services.policies; // eslint-disable-line no-unused-expressions + +AddonTestUtils.init(this); + +add_task(async function test_storage_managed_policy() { + await ExtensionTestUtils.startAddonManager(); + + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + "3rdparty": { + Extensions: { + "test-storage-managed-policy@mozilla.com": { + string: "value", + }, + }, + }, + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { id: "test-storage-managed-policy@mozilla.com" }, + }, + permissions: ["storage"], + }, + + async background() { + let str = await browser.storage.managed.get("string"); + browser.test.sendMessage("results", str); + }, + }); + + await extension.startup(); + deepEqual(await extension.awaitMessage("results"), { string: "value" }); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js new file mode 100644 index 0000000000..b9dc8a0212 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js @@ -0,0 +1,82 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionStorageIDB", + "resource://gre/modules/ExtensionStorageIDB.jsm" +); + +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +AddonTestUtils.init(this); + +add_task(async function setup() { + // Ensure that the IDB backend is enabled. + Services.prefs.setBoolPref("ExtensionStorageIDB.BACKEND_ENABLED_PREF", true); + + Services.prefs.setBoolPref("dom.quotaManager.testing", true); + Services.prefs.setIntPref( + "dom.quotaManager.temporaryStorage.fixedLimit", + 100 + ); + await promiseQuotaManagerServiceReset(); + + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_storage_local_set_quota_exceeded_error() { + const EXT_ID = "test-quota-exceeded@mochi.test"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXT_ID } }, + }, + async background() { + const data = new Array(1000 * 1024).fill("x").join(""); + await browser.test.assertRejects( + browser.storage.local.set({ data }), + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + browser.test.sendMessage("data-stored"); + }, + }; + + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); + Services.prefs.clearUserPref(LEAVE_UUID_PREF); + }); + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + // Run test on a test extension being migrated to the IDB backend. + await extension.startup(); + await extension.awaitMessage("data-stored"); + + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The extension has been successfully migrated to the IDB backend" + ); + await extension.unload(); + + // Run again on a test extension already already migrated to the IDB backend. + const extensionUpdated = ExtensionTestUtils.loadExtension(extensionDef); + await extensionUpdated.startup(); + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The extension has been successfully migrated to the IDB backend" + ); + await extensionUpdated.awaitMessage("data-stored"); + + await extensionUpdated.unload(); + + Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit"); + await promiseQuotaManagerServiceClear(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js new file mode 100644 index 0000000000..38d1de29fa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js @@ -0,0 +1,106 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Sanitizer", + "resource:///modules/Sanitizer.jsm" +); + +async function test_sanitize_offlineApps(storageHelpersScript) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + background: { + scripts: ["storageHelpers.js", "background.js"], + }, + }, + files: { + "storageHelpers.js": storageHelpersScript, + "background.js": function() { + browser.test.onMessage.addListener(async (msg, args) => { + let result = {}; + switch (msg) { + case "set-storage-data": + await window.testWriteKey(...args); + break; + case "get-storage-data": + const value = await window.testReadKey(args[0]); + browser.test.assertEq(args[1], value, "Got the expected value"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`, result); + }); + }, + }, + }); + + await extension.startup(); + + extension.sendMessage("set-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("set-storage-data:done"); + + await extension.sendMessage("get-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("get-storage-data:done"); + + info("Verify the extension data not cleared by offlineApps Sanitizer"); + await Sanitizer.sanitize(["offlineApps"]); + await extension.sendMessage("get-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("get-storage-data:done"); + + await extension.unload(); +} + +add_task(async function test_sanitize_offlineApps_extension_indexedDB() { + await test_sanitize_offlineApps(function indexedDBStorageHelpers() { + const getIDBStore = () => + new Promise(resolve => { + let dbreq = window.indexedDB.open("TestDB"); + dbreq.onupgradeneeded = () => + dbreq.result.createObjectStore("TestStore"); + dbreq.onsuccess = () => resolve(dbreq.result); + }); + + // Export writeKey and readKey storage test helpers. + window.testWriteKey = (k, v) => + getIDBStore().then(db => { + const tx = db.transaction("TestStore", "readwrite"); + const store = tx.objectStore("TestStore"); + return new Promise((resolve, reject) => { + tx.oncomplete = evt => resolve(evt.target.result); + tx.onerror = evt => reject(evt.target.error); + store.add(v, k); + }); + }); + window.testReadKey = k => + getIDBStore().then(db => { + const tx = db.transaction("TestStore"); + const store = tx.objectStore("TestStore"); + return new Promise((resolve, reject) => { + const req = store.get(k); + tx.oncomplete = evt => resolve(req.result); + tx.onerror = evt => reject(evt.target.error); + }); + }); + }); +}); + +add_task( + { + // Skip this test if LSNG is not enabled (because this test is only + // going to pass when nextgen local storage is being used). + skip_if: () => !Services.prefs.getBoolPref("dom.storage.next_gen"), + }, + async function test_sanitize_offlineApps_extension_localStorage() { + await test_sanitize_offlineApps(function indexedDBStorageHelpers() { + // Export writeKey and readKey storage test helpers. + window.testWriteKey = (k, v) => window.localStorage.setItem(k, v); + window.testReadKey = k => window.localStorage.getItem(k); + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js new file mode 100644 index 0000000000..ad821c5a07 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js @@ -0,0 +1,29 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(test_config_flag_needed); + +add_task(test_sync_reloading_extensions_works); + +add_task(function test_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_page_storage("sync") + ); +}); + +add_task(test_storage_sync_requires_real_id); + +add_task(function test_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_storage_area_with_bytes_in_use("sync", true) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js new file mode 100644 index 0000000000..db7091db8d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js @@ -0,0 +1,2290 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a kinto-specific test... +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +do_get_profile(); // so we can use FxAccounts + +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { + cleanUpForContext, + CollectionKeyEncryptionRemoteTransformer, + CryptoCollection, + ExtensionStorageSync, + idToKey, + keyToId, + KeyRingEncryptionRemoteTransformer, +} = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSyncKinto.jsm", + null +); +const { BulkKeyBundle } = ChromeUtils.import( + "resource://services-sync/keys.js" +); +const { FxAccountsKeys } = ChromeUtils.import( + "resource://gre/modules/FxAccountsKeys.jsm" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +const { createAppInfo, promiseStartupManager } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "69"); + +function handleCannedResponse(cannedResponse, request, response) { + response.setStatusLine( + null, + cannedResponse.status.status, + cannedResponse.status.statusText + ); + // send the headers + for (let headerLine of cannedResponse.sampleHeaders) { + let headerElements = headerLine.split(":"); + response.setHeader(headerElements[0], headerElements[1].trimLeft()); + } + response.setHeader("Date", new Date().toUTCString()); + + response.write(cannedResponse.responseBody); +} + +function collectionPath(collectionId) { + return `/buckets/default/collections/${collectionId}`; +} + +function collectionRecordsPath(collectionId) { + return `/buckets/default/collections/${collectionId}/records`; +} + +class KintoServer { + constructor() { + // Set up an HTTP Server + this.httpServer = new HttpServer(); + this.httpServer.start(-1); + + // Set<Object> corresponding to records that might be served. + // The format of these objects is defined in the documentation for #addRecord. + this.records = []; + + // Collections that we have set up access to (see `installCollection`). + this.collections = new Set(); + + // ETag to serve with responses + this.etag = 1; + + this.port = this.httpServer.identity.primaryPort; + + // POST requests we receive from the client go here + this.posts = []; + // DELETEd buckets will go here. + this.deletedBuckets = []; + // Anything in here will force the next POST to generate a conflict + this.conflicts = []; + // If this is true, reject the next request with a 401 + this.rejectNextAuthResponse = false; + this.failedAuths = []; + + this.installConfigPath(); + this.installBatchPath(); + this.installCatchAll(); + } + + clearPosts() { + this.posts = []; + } + + getPosts() { + return this.posts; + } + + getDeletedBuckets() { + return this.deletedBuckets; + } + + rejectNextAuthWith(response) { + this.rejectNextAuthResponse = response; + } + + checkAuth(request, response) { + equal(request.getHeader("Authorization"), "Bearer some-access-token"); + + if (this.rejectNextAuthResponse) { + response.setStatusLine(null, 401, "Unauthorized"); + response.write(this.rejectNextAuthResponse); + this.rejectNextAuthResponse = false; + this.failedAuths.push(request); + return true; + } + return false; + } + + installConfigPath() { + const configPath = "/v1/"; + const responseBody = JSON.stringify({ + settings: { batch_max_requests: 25 }, + url: `http://localhost:${this.port}/v1/`, + documentation: "https://kinto.readthedocs.org/", + version: "1.5.1", + commit: "cbc6f58", + hello: "kinto", + }); + const configResponse = { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + status: { status: 200, statusText: "OK" }, + responseBody: responseBody, + }; + + function handleGetConfig(request, response) { + if (request.method != "GET") { + dump(`ARGH, got ${request.method}\n`); + } + return handleCannedResponse(configResponse, request, response); + } + + this.httpServer.registerPathHandler(configPath, handleGetConfig); + } + + installBatchPath() { + const batchPath = "/v1/batch"; + + function handlePost(request, response) { + if (this.checkAuth(request, response)) { + return; + } + + let bodyStr = CommonUtils.readBytesFromInputStream( + request.bodyInputStream + ); + let body = JSON.parse(bodyStr); + let defaults = body.defaults; + for (let req of body.requests) { + let headers = Object.assign( + {}, + (defaults && defaults.headers) || {}, + req.headers + ); + this.posts.push(Object.assign({}, req, { headers })); + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + + let postResponse = { + responses: body.requests.map(req => { + let oneBody; + if (req.method == "DELETE") { + let id = req.path.match( + /^\/buckets\/default\/collections\/.+\/records\/(.+)$/ + )[1]; + oneBody = { + data: { + deleted: true, + id: id, + last_modified: this.etag, + }, + }; + } else { + oneBody = { + data: Object.assign({}, req.body.data, { + last_modified: this.etag, + }), + permissions: [], + }; + } + + return { + path: req.path, + status: 201, // FIXME -- only for new posts?? + headers: { ETag: 3000 }, // FIXME??? + body: oneBody, + }; + }), + }; + + if (this.conflicts.length) { + const nextConflict = this.conflicts.shift(); + if (!nextConflict.transient) { + this.records.push(nextConflict); + } + const { data } = nextConflict; + postResponse = { + responses: body.requests.map(req => { + return { + path: req.path, + status: 412, + headers: { ETag: this.etag }, // is this correct?? + body: { + details: { + existing: data, + }, + }, + }; + }), + }; + } + + response.write(JSON.stringify(postResponse)); + + // "sampleHeaders": [ + // "Access-Control-Allow-Origin: *", + // "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + // "Server: waitress", + // "Etag: \"4000\"" + // ], + } + + this.httpServer.registerPathHandler(batchPath, handlePost.bind(this)); + } + + installCatchAll() { + this.httpServer.registerPathHandler("/", (request, response) => { + dump( + `got request: ${request.method}:${request.path}?${request.queryString}\n` + ); + dump( + `${CommonUtils.readBytesFromInputStream(request.bodyInputStream)}\n` + ); + }); + } + + /** + * Add a record to those that can be served by this server. + * + * @param {Object} properties An object describing the record that + * should be served. The properties of this object are: + * - collectionId {string} This record should only be served if a + * request is for this collection. + * - predicate {Function} If present, this record should only be served if the + * predicate returns true. The predicate will be called with + * {request: Request, response: Response, since: number, server: KintoServer}. + * - data {string} The record to serve. + * - conflict {boolean} If present and true, this record is added to + * "conflicts" and won't be served, but will cause a conflict on + * the next push. + */ + addRecord(properties) { + if (!properties.conflict) { + this.records.push(properties); + } else { + this.conflicts.push(properties); + } + + this.installCollection(properties.collectionId); + } + + /** + * Tell the server to set up a route for this collection. + * + * This will automatically be called for any collection to which you `addRecord`. + * + * @param {string} collectionId the collection whose route we + * should set up. + */ + installCollection(collectionId) { + if (this.collections.has(collectionId)) { + return; + } + this.collections.add(collectionId); + const remoteCollectionPath = + "/v1" + collectionPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler( + remoteCollectionPath, + this.handleGetCollection.bind(this, collectionId) + ); + const remoteRecordsPath = + "/v1" + collectionRecordsPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler( + remoteRecordsPath, + this.handleGetRecords.bind(this, collectionId) + ); + } + + handleGetCollection(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.write( + JSON.stringify({ + data: { + id: collectionId, + }, + }) + ); + } + + handleGetRecords(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + if (request.method != "GET") { + do_throw(`only GET is supported on ${request.path}`); + } + + let sinceMatch = request.queryString.match(/(^|&)_since=(\d+)/); + let since = sinceMatch && parseInt(sinceMatch[2], 10); + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.setHeader("ETag", this.etag.toString()); + + const records = this.records + .filter(properties => { + if (properties.collectionId != collectionId) { + return false; + } + + if (properties.predicate) { + const predAllowed = properties.predicate({ + request: request, + response: response, + since: since, + server: this, + }); + if (!predAllowed) { + return false; + } + } + + return true; + }) + .map(properties => properties.data); + + const body = JSON.stringify({ + data: records, + }); + response.write(body); + } + + installDeleteBucket() { + this.httpServer.registerPrefixHandler( + "/v1/buckets/", + (request, response) => { + if (request.method != "DELETE") { + dump( + `got a non-delete action on bucket: ${request.method} ${request.path}\n` + ); + return; + } + + const noPrefix = request.path.slice("/v1/buckets/".length); + const [bucket, afterBucket] = noPrefix.split("/", 1); + if (afterBucket && afterBucket != "") { + dump( + `got a delete for a non-bucket: ${request.method} ${request.path}\n` + ); + } + + this.deletedBuckets.push(bucket); + // Fake like this actually deletes the records. + this.records = []; + + response.write( + JSON.stringify({ + data: { + deleted: true, + last_modified: 1475161309026, + id: "b09f1618-d789-302d-696e-74ec53ee18a8", // FIXME + }, + }) + ); + } + ); + } + + // Utility function to install a keyring at the start of a test. + async installKeyRing(fxaService, keysData, salts, etag, properties) { + const keysRecord = { + id: "keys", + keys: keysData, + salts: salts, + last_modified: etag, + }; + this.etag = etag; + const transformer = new KeyRingEncryptionRemoteTransformer(fxaService); + return this.encryptAndAddRecord( + transformer, + Object.assign({}, properties, { + collectionId: "storage-sync-crypto", + data: keysRecord, + }) + ); + } + + encryptAndAddRecord(transformer, properties) { + return transformer.encode(properties.data).then(encrypted => { + this.addRecord(Object.assign({}, properties, { data: encrypted })); + }); + } + + stop() { + this.httpServer.stop(() => {}); + } +} + +/** + * Predicate that represents a record appearing at some time. + * Requests with "_since" before this time should see this record, + * unless the server itself isn't at this time yet (etag is before + * this time). + * + * Requests with _since after this time shouldn't see this record any + * more, since it hasn't changed after this time. + * + * @param {int} startTime the etag at which time this record should + * start being available (and thus, the predicate should start + * returning true) + * @returns {Function} + */ +function appearsAt(startTime) { + return function({ since, server }) { + return since < startTime && startTime < server.etag; + }; +} + +// Run a block of code with access to a KintoServer. +async function withServer(f) { + let server = new KintoServer(); + // Point the sync.storage client to use the test server we've just started. + Services.prefs.setCharPref( + "webextensions.storage.sync.serverURL", + `http://localhost:${server.port}/v1` + ); + try { + await f(server); + } finally { + server.stop(); + } +} + +// Run a block of code with access to both a sync context and a +// KintoServer. This is meant as a workaround for eslint's refusal to +// let me have 5 nested callbacks. +async function withContextAndServer(f) { + await withSyncContext(async function(context) { + await withServer(async function(server) { + await f(context, server); + }); + }); +} + +// Run a block of code with fxa mocked out to return a specific user. +// Calls the given function with an ExtensionStorageSync instance that +// was constructed using a mocked FxAccounts instance. +async function withSignedInUser(user, f) { + let fxaServiceMock = { + getSignedInUser() { + return Promise.resolve({ uid: user.uid }); + }, + getOAuthToken() { + return Promise.resolve("some-access-token"); + }, + checkAccountStatus() { + return Promise.resolve(true); + }, + removeCachedOAuthToken() { + return Promise.resolve(); + }, + keys: { + getKeyForScope(scope) { + return Promise.resolve({ ...user.scopedKeys[scope] }); + }, + kidAsHex(jwk) { + return new FxAccountsKeys({}).kidAsHex(jwk); + }, + }, + }; + + let telemetryMock = { + _calls: [], + _histograms: {}, + scalarSet(name, value) { + this._calls.push({ method: "scalarSet", name, value }); + }, + keyedScalarSet(name, key, value) { + this._calls.push({ method: "keyedScalarSet", name, key, value }); + }, + getKeyedHistogramById(name) { + let self = this; + return { + add(key, value) { + if (!self._histograms[name]) { + self._histograms[name] = []; + } + self._histograms[name].push(value); + }, + }; + }, + }; + let extensionStorageSync = new ExtensionStorageSync( + fxaServiceMock, + telemetryMock + ); + await f(extensionStorageSync, fxaServiceMock); +} + +// Some assertions that make it easier to write tests about what was +// posted and when. + +// Assert that a post in a batch was made with the correct access token. +// This should be true of all requests, so this is usually called from +// another assertion. +function assertAuthenticatedPost(post) { + equal(post.headers.Authorization, "Bearer some-access-token"); +} + +// Assert that this post was made with the correct request headers to +// create a new resource while protecting against someone else +// creating it at the same time (in other words, "If-None-Match: *"). +// Also calls assertAuthenticatedPost(post). +function assertPostedNewRecord(post) { + assertAuthenticatedPost(post); + equal(post.headers["If-None-Match"], "*"); +} + +// Assert that this post was made with the correct request headers to +// update an existing resource while protecting against concurrent +// modification (in other words, `If-Match: "${etag}"`). +// Also calls assertAuthenticatedPost(post). +function assertPostedUpdatedRecord(post, since) { + assertAuthenticatedPost(post); + equal(post.headers["If-Match"], `"${since}"`); +} + +// Assert that this post was an encrypted keyring, and produce the +// decrypted body. Sanity check the body while we're here. +const assertPostedEncryptedKeys = async function(fxaService, post) { + equal(post.path, collectionRecordsPath("storage-sync-crypto") + "/keys"); + + let body = await new KeyRingEncryptionRemoteTransformer(fxaService).decode( + post.body.data + ); + ok(body.keys, `keys object should be present in decoded body`); + ok(body.keys.default, `keys object should have a default key`); + ok(body.salts, `salts object should be present in decoded body`); + return body; +}; + +// assertEqual, but for keyring[extensionId] == key. +function assertKeyRingKey(keyRing, extensionId, expectedKey, message) { + if (!message) { + message = `expected keyring's key for ${extensionId} to match ${expectedKey.keyPairB64}`; + } + ok( + keyRing.hasKeysFor([extensionId]), + `expected keyring to have a key for ${extensionId}\n` + ); + deepEqual( + keyRing.keyForCollection(extensionId).keyPairB64, + expectedKey.keyPairB64, + message + ); +} + +// Assert that this post was posted for a given extension. +const assertExtensionRecord = async function(fxaService, post, extension, key) { + const extensionId = extension.id; + const cryptoCollection = new CryptoCollection(fxaService); + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt(keyToId(key), extensionId)); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + const transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "decrypted data should be posted to path corresponding to its key" + ); + let decoded = await transformer.decode(post.body.data); + equal( + decoded.key, + key, + "decrypted data should have a key attribute corresponding to the extension data key" + ); + return decoded; +}; + +// Tests using this ID will share keys in local storage, so be careful. +const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}"; +const defaultExtension = { id: defaultExtensionId }; + +const loggedInUser = { + uid: "0123456789abcdef0123456789abcdef", + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAA", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMQ", + kty: "oct", + }, + }, + oauthTokens: { + "sync:addon_storage": { + token: "some-access-token", + }, + }, +}; + +function uuid() { + const uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService( + Ci.nsIUUIDGenerator + ); + return uuidgen.generateUUID().toString(); +} + +add_task(async function test_setup() { + await promiseStartupManager(); +}); + +add_task(async function test_single_initialization() { + // Grab access to this via the backstage pass to check if we're calling openConnection too often. + const { FirefoxAdapter } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSyncKinto.jsm", + null + ); + const origOpenConnection = FirefoxAdapter.openConnection; + let callCount = 0; + FirefoxAdapter.openConnection = function(...args) { + ++callCount; + return origOpenConnection.apply(this, args); + }; + function background() { + let promises = ["foo", "bar", "baz", "quux"].map(key => + browser.storage.sync.get(key) + ); + Promise.all(promises).then(() => + browser.test.notifyPass("initialize once") + ); + } + try { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})()`, + }); + + await extension.startup(); + await extension.awaitFinish("initialize once"); + await extension.unload(); + equal( + callCount, + 1, + "Initialized FirefoxAdapter connection and Kinto exactly once" + ); + } finally { + FirefoxAdapter.openConnection = origOpenConnection; + } +}); + +add_task(async function test_key_to_id() { + equal(keyToId("foo"), "key-foo"); + equal(keyToId("my-new-key"), "key-my_2D_new_2D_key"); + equal(keyToId(""), "key-"); + equal(keyToId("™"), "key-_2122_"); + equal(keyToId("\b"), "key-_8_"); + equal(keyToId("abc\ndef"), "key-abc_A_def"); + equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string"); + + const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string", "™", "\b"]; + for (let key of KEYS) { + equal(idToKey(keyToId(key)), key); + } + + equal(idToKey("hi"), null); + equal(idToKey("-key-hi"), null); + equal(idToKey("key--abcd"), null); + equal(idToKey("key-%"), null); + equal(idToKey("key-_HI"), null); + equal(idToKey("key-_HI_"), null); + equal(idToKey("key-"), ""); + equal(idToKey("key-1"), "1"); + equal(idToKey("key-_2D_"), "-"); +}); + +add_task(async function test_extension_id_to_collection_id() { + const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}"; + // FIXME: this doesn't actually require the signed in user, but the + // extensionIdToCollectionId method exists on CryptoCollection, + // which needs an fxaService to be instantiated. + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + // Fake a static keyring since the server doesn't exist. + const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo="; + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._setSalt(extensionId, salt); + + equal( + await cryptoCollection.extensionIdToCollectionId(extensionId), + "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo" + ); + }); +}); + +add_task(async function ensureCanSync_clearAll() { + // A test extension that will not have any active context around + // but it is returned from a call to AddonManager.getExtensionsByType. + const extensionId = "test-wipe-on-enabled-and-synced@mochi.test"; + const testExtension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { gecko: { id: extensionId } }, + }, + }); + + await testExtension.startup(); + + // Retrieve the Extension class instance from the test extension. + const { extension } = testExtension; + + // Another test extension that will have an active extension context. + const extensionId2 = "test-wipe-on-active-context@mochi.test"; + const extension2 = { id: extensionId2 }; + + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + async function assertSetAndGetData(extension, data) { + await extensionStorageSync.set(extension, data, context); + let storedData = await extensionStorageSync.get( + extension, + Object.keys(data), + context + ); + const extId = extensionId; + deepEqual(storedData, data, `${extId} should get back the data we set`); + } + + async function assertDataCleared(extension, keys) { + const storedData = await extensionStorageSync.get( + extension, + keys, + context + ); + deepEqual(storedData, {}, `${extension.id} should have lost the data`); + } + + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionId2, + ]); + ok( + newKeys.hasKeysFor([extensionId]), + `key isn't present for ${extensionId}` + ); + ok( + newKeys.hasKeysFor([extensionId2]), + `key isn't present for ${extensionId2}` + ); + + let posts = server.getPosts(); + equal(posts.length, 1); + assertPostedNewRecord(posts[0]); + + await assertSetAndGetData(extension, { "my-key": 1 }); + await assertSetAndGetData(extension2, { "my-key": 2 }); + + // Call cleanup for the first extension, to double check it has + // been wiped out even without an active extension context. + cleanUpForContext(extension, context); + + // clear everything. + await extensionStorageSync.clearAll(); + + // Assert that the data is gone for both the extensions. + await assertDataCleared(extension, ["my-key"]); + await assertDataCleared(extension2, ["my-key"]); + + // should have been no posts caused by the clear. + posts = server.getPosts(); + equal(posts.length, 1); + }); + }); + + await testExtension.unload(); +}); + +add_task(async function ensureCanSync_posts_new_keys() { + const extensionId = uuid(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([extensionId]); + ok( + newKeys.hasKeysFor([extensionId]), + `key isn't present for ${extensionId}` + ); + + let posts = server.getPosts(); + equal(posts.length, 1); + const post = posts[0]; + assertPostedNewRecord(post); + const body = await assertPostedEncryptedKeys(fxaService, post); + const oldSalt = body.salts[extensionId]; + ok( + body.keys.collections[extensionId], + `keys object should have a key for ${extensionId}` + ); + ok(oldSalt, `salts object should have a salt for ${extensionId}`); + + // Try adding another key to make sure that the first post was + // OK, even on a new profile. + await extensionStorageSync.cryptoCollection._clear(); + server.clearPosts(); + // Restore the first posted keyring, but add a last_modified date + const firstPostedKeyring = Object.assign({}, post.body.data, { + last_modified: server.etag, + }); + server.addRecord({ + data: firstPostedKeyring, + collectionId: "storage-sync-crypto", + predicate: appearsAt(250), + }); + const extensionId2 = uuid(); + newKeys = await extensionStorageSync.ensureCanSync([extensionId2]); + ok( + newKeys.hasKeysFor([extensionId]), + `didn't forget key for ${extensionId}` + ); + ok( + newKeys.hasKeysFor([extensionId2]), + `new key generated for ${extensionId2}` + ); + + posts = server.getPosts(); + equal(posts.length, 1); + const newPost = posts[posts.length - 1]; + const newBody = await assertPostedEncryptedKeys(fxaService, newPost); + ok( + newBody.keys.collections[extensionId], + `keys object should have a key for ${extensionId}` + ); + ok( + newBody.keys.collections[extensionId2], + `keys object should have a key for ${extensionId2}` + ); + ok( + newBody.salts[extensionId], + `salts object should have a key for ${extensionId}` + ); + ok( + newBody.salts[extensionId2], + `salts object should have a key for ${extensionId2}` + ); + equal( + oldSalt, + newBody.salts[extensionId], + `old salt should be preserved in post` + ); + }); + }); +}); + +add_task(async function ensureCanSync_pulls_key() { + // ensureCanSync is implemented by adding a key to our local record + // and doing a sync. This means that if the same key exists + // remotely, we get a "conflict". Ensure that we handle this + // correctly -- we keep the server key (since presumably it's + // already been used to encrypt records) and we don't wipe out other + // collections' keys. + const extensionId = uuid(); + const extensionId2 = uuid(); + const extensionOnlyKey = uuid(); + const extensionOnlySalt = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + await DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + await RANDOM_KEY.generateRandom(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + // FIXME: generating a random salt probably shouldn't require a CryptoCollection? + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + await extensionStorageSync.cryptoCollection._clear(); + const keysData = { + default: DEFAULT_KEY.keyPairB64, + collections: { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + await server.installKeyRing(fxaService, keysData, saltData, 950, { + predicate: appearsAt(900), + }); + + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY); + + let posts = server.getPosts(); + equal( + posts.length, + 0, + "ensureCanSync shouldn't push when the server keyring has the right key" + ); + + // Another client generates a key for extensionId2 + const newKey = new BulkKeyBundle(extensionId2); + await newKey.generateRandom(); + keysData.collections[extensionId2] = newKey.keyPairB64; + saltData[extensionId2] = cryptoCollection.getNewSalt(); + await server.installKeyRing(fxaService, keysData, saltData, 1050, { + predicate: appearsAt(1000), + }); + + let newCollectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionId2, + ]); + assertKeyRingKey(newCollectionKeys, extensionId2, newKey); + assertKeyRingKey( + newCollectionKeys, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + + posts = server.getPosts(); + equal(posts.length, 0, "ensureCanSync shouldn't push when updating keys"); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const onlyKey = new BulkKeyBundle(extensionOnlyKey); + await onlyKey.generateRandom(); + keysData.collections[extensionOnlyKey] = onlyKey.keyPairB64; + await server.installKeyRing(fxaService, keysData, saltData, 1150, { + predicate: appearsAt(1100), + }); + + let withNewKey = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionOnlyKey, + ]); + dump(`got ${JSON.stringify(withNewKey.asWBO().cleartext)}\n`); + assertKeyRingKey(withNewKey, extensionOnlyKey, onlyKey); + assertKeyRingKey( + withNewKey, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + + posts = server.getPosts(); + equal( + posts.length, + 1, + "ensureCanSync should push when generating a new salt" + ); + const withNewKeyRecord = await assertPostedEncryptedKeys( + fxaService, + posts[0] + ); + // We don't a priori know what the new salt is + dump(`${JSON.stringify(withNewKeyRecord)}\n`); + ok( + withNewKeyRecord.salts[extensionOnlyKey], + `ensureCanSync should generate a salt for an extension that only had a key` + ); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const newSalt = cryptoCollection.getNewSalt(); + saltData[extensionOnlySalt] = newSalt; + await server.installKeyRing(fxaService, keysData, saltData, 1250, { + predicate: appearsAt(1200), + }); + + let withOnlySaltKey = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionOnlySalt, + ]); + assertKeyRingKey( + withOnlySaltKey, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + // We don't a priori know what the new key is + ok( + withOnlySaltKey.hasKeysFor([extensionOnlySalt]), + `ensureCanSync generated a key for an extension that only had a salt` + ); + + posts = server.getPosts(); + equal( + posts.length, + 2, + "ensureCanSync should push when generating a new key" + ); + const withNewSaltRecord = await assertPostedEncryptedKeys( + fxaService, + posts[1] + ); + equal( + withNewSaltRecord.salts[extensionOnlySalt], + newSalt, + "ensureCanSync should keep the existing salt when generating only a key" + ); + }); + }); +}); + +add_task(async function ensureCanSync_handles_conflicts() { + // Syncing is done through a pull followed by a push of any merged + // changes. Accordingly, the only way to have a "true" conflict -- + // i.e. with the server rejecting a change -- is if + // someone pushes changes between our pull and our push. Ensure that + // if this happens, we still behave sensibly (keep the remote key). + const extensionId = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + await DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + await RANDOM_KEY.generateRandom(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + // FIXME: generating salts probably shouldn't rely on a CryptoCollection + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + const keysData = { + default: DEFAULT_KEY.keyPairB64, + collections: { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + await server.installKeyRing(fxaService, keysData, saltData, 765, { + conflict: true, + }); + + await extensionStorageSync.cryptoCollection._clear(); + + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + assertKeyRingKey( + collectionKeys, + extensionId, + RANDOM_KEY, + `syncing keyring should keep the server key for ${extensionId}` + ); + + let posts = server.getPosts(); + equal( + posts.length, + 1, + "syncing keyring should have tried to post a keyring" + ); + const failedPost = posts[0]; + assertPostedNewRecord(failedPost); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + // This key will be the one the client generated locally, so + // we don't know what its value will be + ok( + body.keys.collections[extensionId], + `decrypted failed post should have a key for ${extensionId}` + ); + notEqual( + body.keys.collections[extensionId], + RANDOM_KEY.keyPairB64, + `decrypted failed post should have a randomly-generated key for ${extensionId}` + ); + }); + }); +}); + +add_task(async function ensureCanSync_handles_deleted_conflicts() { + // A keyring can be deleted, and this changes the format of the 412 + // Conflict response from the Kinto server. Make sure we handle it correctly. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function(context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + server.etag = 700; + await extensionStorageSync.cryptoCollection._clear(); + + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // This is the response that the Kinto server return when the + // keyring has been deleted. + server.addRecord({ + collectionId: "storage-sync-crypto", + conflict: true, + transient: true, + data: null, + etag: 765, + }); + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([ + extensionId2, + ]); + + assertKeyRingKey( + collectionKeys2, + extensionId, + extensionKey, + `syncing keyring should keep our local key for ${extensionId}` + ); + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "syncing keyring should have tried to post a keyring twice" + ); + // The first post got a conflict. + const failedPost = posts[0]; + assertPostedUpdatedRecord(failedPost, 700); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + + deepEqual( + body.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted failed post should have the key for ${extensionId}` + ); + + // The second post was after the wipe, and succeeded. + const afterWipePost = posts[1]; + assertPostedNewRecord(afterWipePost); + let afterWipeBody = await assertPostedEncryptedKeys( + fxaService, + afterWipePost + ); + + deepEqual( + afterWipeBody.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}` + ); + }); + }); +}); + +add_task(async function ensureCanSync_handles_flushes() { + // See Bug 1359879 and Bug 1350088. One of the ways that 1359879 presents is + // as 1350088. This seems to be the symptom that results when the user had + // two devices, one of which was not syncing at the time the keyring was + // lost. Ensure we can recover for these users as well. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function(context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + server.etag = 700; + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // last_modified is new, but there is no data. + server.etag = 800; + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([ + extensionId2, + ]); + + assertKeyRingKey( + collectionKeys2, + extensionId, + extensionKey, + `syncing keyring should keep our local key for ${extensionId}` + ); + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal( + posts.length, + 1, + "syncing keyring should have tried to post a keyring once" + ); + + const post = posts[0]; + assertPostedNewRecord(post); + let postBody = await assertPostedEncryptedKeys(fxaService, post); + + deepEqual( + postBody.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}` + ); + }); + }); +}); + +add_task(async function checkSyncKeyRing_reuploads_keys() { + // Verify that when keys are present, they are reuploaded with the + // new kbHash when we call touchKeys(). + const extensionId = uuid(); + let extensionKey, extensionSalt; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + server.installCollection("storage-sync-crypto"); + server.etag = 765; + + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should return a keyring that has a key for ${extensionId}` + ); + extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + equal( + server.getPosts().length, + 1, + "generating a key that doesn't exist on the server should post it" + ); + const body = await assertPostedEncryptedKeys( + fxaService, + server.getPosts()[0] + ); + extensionSalt = body.salts[extensionId]; + }); + + // The user changes their password. This is their new kbHash, with + // the last character changed. + const newUser = Object.assign({}, loggedInUser, { + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, + }); + let postedKeys; + await withSignedInUser(newUser, async function( + extensionStorageSync, + fxaService + ) { + await extensionStorageSync.checkSyncKeyRing(); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "when kBHash changes, checkSyncKeyRing should post the keyring reencrypted with the new kBHash" + ); + postedKeys = posts[1]; + assertPostedUpdatedRecord(postedKeys, 765); + + let body = await assertPostedEncryptedKeys(fxaService, postedKeys); + deepEqual( + body.keys.collections[extensionId], + extensionKey, + `the posted keyring should have the same key for ${extensionId} as the old one` + ); + deepEqual( + body.salts[extensionId], + extensionSalt, + `the posted keyring should have the same salt for ${extensionId} as the old one` + ); + }); + + // Verify that with the old kBHash, we can't decrypt the record. + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + let error; + try { + await new KeyRingEncryptionRemoteTransformer(fxaService).decode( + postedKeys.body.data + ); + } catch (e) { + error = e; + } + ok(error, "decrypting the keyring with the old kBHash should fail"); + ok( + Utils.isHMACMismatch(error) || + KeyRingEncryptionRemoteTransformer.isOutdatedKB(error), + "decrypting the keyring with the old kBHash should throw an HMAC mismatch" + ); + }); + }); +}); + +add_task(async function checkSyncKeyRing_overwrites_on_conflict() { + // If there is already a record on the server that was encrypted + // with a different kbHash, we wipe the server, clear sync state, and + // overwrite it with our keys. + const extensionId = uuid(); + let extensionKey; + await withSyncContext(async function(context) { + await withServer(async function(server) { + // The old device has this kbHash, which is very similar to the + // current kbHash but with the last character changed. + const oldUser = Object.assign({}, loggedInUser, { + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, + }); + server.installDeleteBucket(); + await withSignedInUser(oldUser, async function( + extensionStorageSync, + fxaService + ) { + await server.installKeyRing(fxaService, {}, {}, 765); + }); + + // Now we have this new user with a different kbHash. + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + // This will try to sync, notice that the record is + // undecryptable, and clear the server. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring with a key for ${extensionId}` + ); + extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal(posts.length, 1, "new keyring should have been uploaded"); + const postedKeys = posts[0]; + // The POST was to an empty server, so etag shouldn't be respected + equal( + postedKeys.headers.Authorization, + "Bearer some-access-token", + "keyring upload should be authorized" + ); + equal( + postedKeys.headers["If-None-Match"], + "*", + "keyring upload should be to empty Kinto server" + ); + equal( + postedKeys.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring upload should be to keyring path" + ); + + let body = await new KeyRingEncryptionRemoteTransformer( + fxaService + ).decode(postedKeys.body.data); + ok(body.uuid, "new keyring should have a UUID"); + equal(typeof body.uuid, "string", "keyring UUIDs should be strings"); + notEqual( + body.uuid, + "abcd", + "new keyring should not have the same UUID as previous keyring" + ); + ok(body.keys, "new keyring should have a keys attribute"); + ok(body.keys.default, "new keyring should have a default key"); + // We should keep the extension key that was in our uploaded version. + deepEqual( + extensionKey, + body.keys.collections[extensionId], + "ensureCanSync should have returned keyring with the same key that was uploaded" + ); + + // This should be a no-op; the keys were uploaded as part of ensurekeysfor + await extensionStorageSync.checkSyncKeyRing(); + equal( + server.getPosts().length, + 1, + "checkSyncKeyRing should not need to post keys after they were reuploaded" + ); + }); + }); + }); +}); + +add_task(async function checkSyncKeyRing_flushes_on_uuid_change() { + // If we can decrypt the record, but the UUID has changed, that + // means another client has wiped the server and reuploaded a + // keyring, so reset sync state and reupload everything. + const extensionId = uuid(); + const extension = { id: extensionId }; + await withSyncContext(async function(context) { + await withServer(async function(server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + const transformer = new KeyRingEncryptionRemoteTransformer(fxaService); + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to get access to keys and salt. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + server.installCollection(collectionId); + + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring that has a key for ${extensionId}` + ); + const extensionKey = collectionKeys.keyForCollection(extensionId) + .keyPairB64; + + // Set something to make sure that it gets re-uploaded when + // uuid changes. + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + await extensionStorageSync.syncAll(); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "should have posted a new keyring and an extension datum" + ); + const postedKeys = posts[0]; + equal( + postedKeys.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "should have posted keyring to /keys" + ); + + let body = await transformer.decode(postedKeys.body.data); + ok(body.uuid, "keyring should have a UUID"); + ok(body.keys, "keyring should have a keys attribute"); + ok(body.keys.default, "keyring should have a default key"); + ok( + body.salts[extensionId], + `keyring should have a salt for ${extensionId}` + ); + const extensionSalt = body.salts[extensionId]; + deepEqual( + extensionKey, + body.keys.collections[extensionId], + "new keyring should have the same key that we uploaded" + ); + + // Another client comes along and replaces the UUID. + // In real life, this would mean changing the keys too, but + // this test verifies that just changing the UUID is enough. + const newKeyRingData = Object.assign({}, body, { + uuid: "abcd", + // Technically, last_modified should be served outside the + // object, but the transformer will pass it through in + // either direction, so this is OK. + last_modified: 765, + }); + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId: "storage-sync-crypto", + data: newKeyRingData, + predicate: appearsAt(800), + }); + + // Fake adding another extension just so that the keyring will + // really get synced. + const newExtension = uuid(); + const newKeyRing = await extensionStorageSync.ensureCanSync([ + newExtension, + ]); + + // This should have detected the UUID change and flushed everything. + // The keyring should, however, be the same, since we just + // changed the UUID of the previously POSTed one. + deepEqual( + newKeyRing.keyForCollection(extensionId).keyPairB64, + extensionKey, + "ensureCanSync should have pulled down a new keyring with the same keys" + ); + + // Syncing should reupload the data for the extension. + await extensionStorageSync.syncAll(); + posts = server.getPosts(); + equal( + posts.length, + 4, + "should have posted keyring for new extension and reuploaded extension data" + ); + + const finalKeyRingPost = posts[2]; + const reuploadedPost = posts[3]; + + equal( + finalKeyRingPost.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring for new extension should have been posted to /keys" + ); + let finalKeyRing = await transformer.decode(finalKeyRingPost.body.data); + equal( + finalKeyRing.uuid, + "abcd", + "newly uploaded keyring should preserve UUID from replacement keyring" + ); + deepEqual( + finalKeyRing.salts[extensionId], + extensionSalt, + "newly uploaded keyring should preserve salts from existing salts" + ); + + // Confirm that the data got reuploaded + let reuploadedData = await assertExtensionRecord( + fxaService, + reuploadedPost, + extension, + "my-key" + ); + equal( + reuploadedData.data, + 5, + "extension data should have a data attribute corresponding to the extension data value" + ); + }); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_changes() { + const extensionId = defaultExtensionId; + const extension = defaultExtension; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.syncAll(); + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue, + 6, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + + equal(calls.length, 1, "syncing calls on-changed listener"); + deepEqual(calls[0][0], { "remote-key": { newValue: 6 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue2, + 7, + "ExtensionStorageSync.get() returns value updated from sync" + ); + + equal(calls.length, 1, "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], { "remote-key": { oldValue: 6, newValue: 7 } }); + }); + }); +}); + +// Tests that an enabled extension which have been synced before it is going +// to be synced on ExtensionStorageSync.syncAll even if there is no active +// context that is currently using the API. +add_task(async function test_storage_sync_on_no_active_context() { + const extensionId = "sync@mochi.test"; + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { gecko: { id: extensionId } }, + }, + files: { + "ext-page.html": `<!DOCTYPE html> + <html> + <head> + <script src="ext-page.js"></script> + </head> + </html> + `, + "ext-page.js": function() { + const { browser } = this; + browser.test.onMessage.addListener(async msg => { + if (msg === "get-sync-data") { + browser.test.sendMessage( + "get-sync-data:done", + await browser.storage.sync.get(["remote-key"]) + ); + } + }); + }, + }, + }); + + await extension.startup(); + + await withServer(async server => { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + + server.etag = 1000; + await extensionStorageSync.syncAll(); + }); + }); + + const extPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/ext-page.html`, + { extension } + ); + + await extension.sendMessage("get-sync-data"); + const res = await extension.awaitMessage("get-sync-data:done"); + Assert.deepEqual(res, { "remote-key": 6 }, "Got the expected sync data"); + + await extPage.close(); + + await extension.unload(); +}); + +add_task(async function test_storage_sync_pushes_changes() { + // FIXME: This test relies on the fact that previous tests pushed + // keys and salts for the default extension ID + const extension = defaultExtension; + const extensionId = defaultExtensionId; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + + // install this AFTER we set the key to 5... + let calls = []; + extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + const localValue = ( + await extensionStorageSync.get(extension, "my-key", context) + )["my-key"]; + equal( + localValue, + 5, + "pushing an ExtensionStorageSync value shouldn't change local value" + ); + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt( + "key-my_2D_key", + extensionId + )); + + let posts = server.getPosts(); + // FIXME: Keys were pushed in a previous test + equal( + posts.length, + 1, + "pushing a value should cause a post to the server" + ); + const post = posts[0]; + assertPostedNewRecord(post); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a value should have a path corresponding to its id" + ); + + const encrypted = post.body.data; + ok( + encrypted.ciphertext, + "pushing a value should post an encrypted record" + ); + ok(!encrypted.data, "pushing a value should not have any plaintext data"); + equal( + encrypted.id, + hashedId, + "pushing a value should use a kinto-friendly record ID" + ); + + const record = await assertExtensionRecord( + fxaService, + post, + extension, + "my-key" + ); + equal( + record.data, + 5, + "when decrypted, a pushed value should have a data field corresponding to its storage.sync value" + ); + equal( + record.id, + "key-my_2D_key", + "when decrypted, a pushed value should have an id field corresponding to its record ID" + ); + + equal( + calls.length, + 0, + "pushing a value shouldn't call the on-changed listener" + ); + + await extensionStorageSync.set(extension, { "my-key": 6 }, context); + await extensionStorageSync.syncAll(); + + // Doesn't push keys because keys were pushed by a previous test. + posts = server.getPosts(); + equal(posts.length, 2, "updating a value should trigger another push"); + const updatePost = posts[1]; + assertPostedUpdatedRecord(updatePost, 1000); + equal( + updatePost.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing an updated value should go to the same path" + ); + + const updateEncrypted = updatePost.body.data; + ok( + updateEncrypted.ciphertext, + "pushing an updated value should still be encrypted" + ); + ok( + !updateEncrypted.data, + "pushing an updated value should not have any plaintext visible" + ); + equal( + updateEncrypted.id, + hashedId, + "pushing an updated value should maintain the same ID" + ); + }); + }); +}); + +add_task(async function test_storage_sync_retries_failed_auth() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + // Put a remote record just to verify that eventually we succeeded + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + // This is a typical response from a production stack if your + // bearer token is bad. + server.rejectNextAuthWith( + '{"code": 401, "errno": 104, "error": "Unauthorized", "message": "Please authenticate yourself to use this endpoint"}' + ); + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 1, "an auth was failed"); + + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue, + 6, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + + // Try again with an emptier JSON body to make sure this still + // works with a less-cooperative server. + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + server.etag = 1000; + // Need to write a JSON response. + // kinto.js 9.0.2 doesn't throw unless there's json. + // See https://github.com/Kinto/kinto-http.js/issues/192. + server.rejectNextAuthWith("{}"); + + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 2, "an auth was failed"); + + const newRemoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + newRemoteValue, + 7, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_conflicts() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.set(extension, { "remote-key": 8 }, context); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal(remoteValue, 8, "locally set value overrides remote value"); + + equal(calls.length, 1, "conflicts manifest in on-changed listener"); + deepEqual(calls[0][0], { "remote-key": { newValue: 8 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue2, + 7, + "conflicts do not prevent retrieval of new values" + ); + + equal(calls.length, 1, "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], { "remote-key": { oldValue: 8, newValue: 7 } }); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_deletes() { + const extension = defaultExtension; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + defaultExtensionId + ); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + await extensionStorageSync.syncAll(); + server.clearPosts(); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + const transformer = new CollectionKeyEncryptionRemoteTransformer( + new CryptoCollection(fxaService), + await cryptoCollection.getKeyRing(), + extension.id + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-my_2D_key", + data: 6, + _status: "deleted", + }, + }); + + await extensionStorageSync.syncAll(); + const remoteValues = await extensionStorageSync.get( + extension, + "my-key", + context + ); + ok( + !remoteValues["my-key"], + "ExtensionStorageSync.get() shows value was deleted by sync" + ); + + equal( + server.getPosts().length, + 0, + "pulling the delete shouldn't cause posts" + ); + + equal(calls.length, 1, "syncing calls on-changed listener"); + deepEqual(calls[0][0], { "my-key": { oldValue: 5 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + }); + }); +}); + +add_task(async function test_storage_sync_pushes_deletes() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._clear(); + await cryptoCollection._setSalt( + extensionId, + cryptoCollection.getNewSalt() + ); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + + let calls = []; + extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + let posts = server.getPosts(); + equal( + posts.length, + 2, + "pushing a non-deleted value should post keys and post the value to the server" + ); + + await extensionStorageSync.remove(extension, ["my-key"], context); + equal( + calls.length, + 1, + "deleting a value should call the on-changed listener" + ); + + await extensionStorageSync.syncAll(); + equal( + calls.length, + 1, + "pushing a deleted value shouldn't call the on-changed listener" + ); + + // Doesn't push keys because keys were pushed by a previous test. + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt( + "key-my_2D_key", + extensionId + )); + posts = server.getPosts(); + equal(posts.length, 3, "deleting a value should trigger another push"); + const post = posts[2]; + assertPostedUpdatedRecord(post, 1000); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a deleted value should go to the same path" + ); + ok(post.method, "PUT"); + ok( + post.body.data.ciphertext, + "deleting a value should have an encrypted body" + ); + const decoded = await new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ).decode(post.body.data); + equal(decoded._status, "deleted"); + // Ideally, we'd check that decoded.deleted is not true, because + // the encrypted record shouldn't have it, but the decoder will + // add it when it sees _status == deleted + }); + }); +}); + +// Some sync tests shared between implementations. +add_task(test_config_flag_needed); + +add_task(test_sync_reloading_extensions_works); + +add_task(function test_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_page_storage("sync") + ); +}); + +add_task(test_storage_sync_requires_real_id); + +add_task(function test_storage_sync_with_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_storage_area_with_bytes_in_use("sync", false) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js new file mode 100644 index 0000000000..8c4137b078 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a kinto-specific test... +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +const { EncryptionRemoteTransformer } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSyncKinto.jsm", + null +); +const { CryptoUtils } = ChromeUtils.import( + "resource://services-crypto/utils.js" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +/** + * Like Assert.throws, but for generators. + * + * @param {string | Object | function} constraint + * What to use to check the exception. + * @param {function} f + * The function to call. + */ +async function throwsGen(constraint, f) { + let threw = false; + let exception; + try { + await f(); + } catch (e) { + threw = true; + exception = e; + } + + ok(threw, "did not throw an exception"); + + const debuggingMessage = `got ${exception}, expected ${constraint}`; + + if (typeof constraint === "function") { + ok(constraint(exception), debuggingMessage); + } else { + let message = exception; + if (typeof exception === "object") { + message = exception.message; + } + ok(constraint === message, debuggingMessage); + } +} + +/** + * An EncryptionRemoteTransformer that uses a fixed key bundle, + * suitable for testing. + */ +class StaticKeyEncryptionRemoteTransformer extends EncryptionRemoteTransformer { + constructor(keyBundle) { + super(); + this.keyBundle = keyBundle; + } + + getKeys() { + return Promise.resolve(this.keyBundle); + } +} +const BORING_KB = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +let transformer; +add_task(async function setup() { + const STRETCHED_KEY = await CryptoUtils.hkdfLegacy( + BORING_KB, + undefined, + `testing storage.sync encryption`, + 2 * 32 + ); + const KEY_BUNDLE = { + sha256HMACHasher: Utils.makeHMACHasher( + Ci.nsICryptoHMAC.SHA256, + Utils.makeHMACKey(STRETCHED_KEY.slice(0, 32)) + ), + encryptionKeyB64: btoa(STRETCHED_KEY.slice(32, 64)), + }; + transformer = new StaticKeyEncryptionRemoteTransformer(KEY_BUNDLE); +}); + +add_task(async function test_encryption_transformer_roundtrip() { + const POSSIBLE_DATAS = [ + "string", + 2, // number + [1, 2, 3], // array + { key: "value" }, // object + ]; + + for (let data of POSSIBLE_DATAS) { + const record = { data, id: "key-some_2D_key", key: "some-key" }; + + deepEqual( + record, + await transformer.decode(await transformer.encode(record)) + ); + } +}); + +add_task(async function test_refuses_to_decrypt_tampered() { + const encryptedRecord = await transformer.encode({ + data: [1, 2, 3], + id: "key-some_2D_key", + key: "some-key", + }); + const tamperedHMAC = Object.assign({}, encryptedRecord, { + hmac: "0000000000000000000000000000000000000000000000000000000000000001", + }); + await throwsGen(Utils.isHMACMismatch, async function() { + await transformer.decode(tamperedHMAC); + }); + + const tamperedIV = Object.assign({}, encryptedRecord, { + IV: "aaaaaaaaaaaaaaaaaaaaaa==", + }); + await throwsGen(Utils.isHMACMismatch, async function() { + await transformer.decode(tamperedIV); + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js new file mode 100644 index 0000000000..7d7f70ee14 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js @@ -0,0 +1,245 @@ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageIDB.jsm" +); + +async function test_multiple_pages() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + function awaitMessage(expectedMsg, api = "test") { + return new Promise(resolve => { + browser[api].onMessage.addListener(function listener(msg) { + if (msg === expectedMsg) { + browser[api].onMessage.removeListener(listener); + resolve(); + } + }); + }); + } + + let tabReady = awaitMessage("tab-ready", "runtime"); + + try { + let storage = browser.storage.local; + + browser.test.sendMessage( + "load-page", + browser.runtime.getURL("tab.html") + ); + await awaitMessage("page-loaded"); + await tabReady; + + let result = await storage.get("key"); + browser.test.assertEq(undefined, result.key, "Key should be undefined"); + + await browser.runtime.sendMessage("tab-set-key"); + + result = await storage.get("key"); + browser.test.assertEq( + JSON.stringify({ foo: { bar: "baz" } }), + JSON.stringify(result.key), + "Key should be set to the value from the tab" + ); + + browser.test.sendMessage("remove-page"); + await awaitMessage("page-removed"); + + result = await storage.get("key"); + browser.test.assertEq( + JSON.stringify({ foo: { bar: "baz" } }), + JSON.stringify(result.key), + "Key should still be set to the value from the tab" + ); + + browser.test.notifyPass("storage-multiple"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage-multiple"); + } + }, + + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="tab.js"></script> + </head> + </html>`, + + "tab.js"() { + browser.test.log("tab"); + browser.runtime.onMessage.addListener(msg => { + if (msg == "tab-set-key") { + return browser.storage.local.set({ key: { foo: { bar: "baz" } } }); + } + }); + + browser.runtime.sendMessage("tab-ready"); + }, + }, + + manifest: { + permissions: ["storage"], + }, + }); + + let contentPage; + extension.onMessage("load-page", async url => { + contentPage = await ExtensionTestUtils.loadContentPage(url, { extension }); + extension.sendMessage("page-loaded"); + }); + extension.onMessage("remove-page", async url => { + await contentPage.close(); + extension.sendMessage("page-removed"); + }); + + await extension.startup(); + await extension.awaitFinish("storage-multiple"); + await extension.unload(); +} + +add_task(async function test_storage_local_file_backend_from_tab() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_multiple_pages + ); +}); + +add_task(async function test_storage_local_idb_backend_from_tab() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], + test_multiple_pages + ); +}); + +async function test_storage_local_call_from_destroying_context() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let numberOfChanges = 0; + browser.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== "local") { + browser.test.fail( + `Received unexpected storage changes for "${areaName}"` + ); + } + + numberOfChanges++; + }); + + browser.test.onMessage.addListener(async ({ msg, values }) => { + switch (msg) { + case "storage-set": { + await browser.storage.local.set(values); + browser.test.sendMessage("storage-set:done"); + break; + } + case "storage-get": { + const res = await browser.storage.local.get(); + browser.test.sendMessage("storage-get:done", res); + break; + } + case "storage-changes": { + browser.test.sendMessage("storage-changes-count", numberOfChanges); + break; + } + default: + browser.test.fail(`Received unexpected message: ${msg}`); + } + }); + + browser.test.sendMessage( + "ext-page-url", + browser.runtime.getURL("tab.html") + ); + }, + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="tab.js"></script> + </head> + </html>`, + + "tab.js"() { + browser.test.log("Extension tab - calling storage.local API method"); + // Call the storage.local API from a tab that is going to be quickly closed. + browser.storage.local.set({ + "test-key-from-destroying-context": "testvalue2", + }); + // Navigate away from the extension page, so that the storage.local API call will be unable + // to send the call to the caller context (because it has been destroyed in the meantime). + window.location = "about:blank"; + }, + }, + manifest: { + permissions: ["storage"], + }, + }); + + await extension.startup(); + const url = await extension.awaitMessage("ext-page-url"); + + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + extension, + }); + let expectedBackgroundPageData = { + "test-key-from-background-page": "test-value", + }; + let expectedTabData = { "test-key-from-destroying-context": "testvalue2" }; + + info( + "Call storage.local.set from the background page and wait it to be completed" + ); + extension.sendMessage({ + msg: "storage-set", + values: expectedBackgroundPageData, + }); + await extension.awaitMessage("storage-set:done"); + + info( + "Call storage.local.get from the background page and wait it to be completed" + ); + extension.sendMessage({ msg: "storage-get" }); + let res = await extension.awaitMessage("storage-get:done"); + + Assert.deepEqual( + res, + { + ...expectedBackgroundPageData, + ...expectedTabData, + }, + "Got the expected data set in the storage.local backend" + ); + + extension.sendMessage({ msg: "storage-changes" }); + equal( + await extension.awaitMessage("storage-changes-count"), + 2, + "Got the expected number of storage.onChanged event received" + ); + + contentPage.close(); + + await extension.unload(); +} + +add_task( + async function test_storage_local_file_backend_destroyed_context_promise() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_storage_local_call_from_destroying_context + ); + } +); + +add_task( + async function test_storage_local_idb_backend_destroyed_context_promise() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], + test_storage_local_call_from_destroying_context + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js new file mode 100644 index 0000000000..f4a7574337 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js @@ -0,0 +1,369 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageIDB.jsm" +); +const { getTrimmedString } = ChromeUtils.import( + "resource://gre/modules/ExtensionTelemetry.jsm" +); +const { TelemetryController } = ChromeUtils.import( + "resource://gre/modules/TelemetryController.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +const HISTOGRAM_JSON_IDS = [ + "WEBEXT_STORAGE_LOCAL_SET_MS", + "WEBEXT_STORAGE_LOCAL_GET_MS", +]; +const KEYED_HISTOGRAM_JSON_IDS = [ + "WEBEXT_STORAGE_LOCAL_SET_MS_BY_ADDONID", + "WEBEXT_STORAGE_LOCAL_GET_MS_BY_ADDONID", +]; + +const HISTOGRAM_IDB_IDS = [ + "WEBEXT_STORAGE_LOCAL_IDB_SET_MS", + "WEBEXT_STORAGE_LOCAL_IDB_GET_MS", +]; +const KEYED_HISTOGRAM_IDB_IDS = [ + "WEBEXT_STORAGE_LOCAL_IDB_SET_MS_BY_ADDONID", + "WEBEXT_STORAGE_LOCAL_IDB_GET_MS_BY_ADDONID", +]; + +const HISTOGRAM_IDS = [].concat(HISTOGRAM_JSON_IDS, HISTOGRAM_IDB_IDS); +const KEYED_HISTOGRAM_IDS = [].concat( + KEYED_HISTOGRAM_JSON_IDS, + KEYED_HISTOGRAM_IDB_IDS +); + +const EXTENSION_ID1 = "@test-extension1"; +const EXTENSION_ID2 = "@test-extension2"; + +async function test_telemetry_background() { + const expectedEmptyHistograms = ExtensionStorageIDB.isBackendEnabled + ? HISTOGRAM_JSON_IDS + : HISTOGRAM_IDB_IDS; + const expectedEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled + ? KEYED_HISTOGRAM_JSON_IDS + : KEYED_HISTOGRAM_IDB_IDS; + + const expectedNonEmptyHistograms = ExtensionStorageIDB.isBackendEnabled + ? HISTOGRAM_IDB_IDS + : HISTOGRAM_JSON_IDS; + const expectedNonEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled + ? KEYED_HISTOGRAM_IDB_IDS + : KEYED_HISTOGRAM_JSON_IDS; + + const server = createHttpServer(); + server.registerDirectory("/data/", do_get_file("data")); + + const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + + async function contentScript() { + await browser.storage.local.set({ a: "b" }); + await browser.storage.local.get("a"); + browser.test.sendMessage("contentDone"); + } + + let baseManifest = { + permissions: ["storage"], + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }; + + let baseExtInfo = { + async background() { + await browser.storage.local.set({ a: "b" }); + await browser.storage.local.get("a"); + browser.test.sendMessage("backgroundDone"); + }, + files: { + "content_script.js": contentScript, + }, + }; + + let extension1 = ExtensionTestUtils.loadExtension({ + ...baseExtInfo, + manifest: { + ...baseManifest, + applications: { + gecko: { id: EXTENSION_ID1 }, + }, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + ...baseExtInfo, + manifest: { + ...baseManifest, + applications: { + gecko: { id: EXTENSION_ID2 }, + }, + }, + }); + + clearHistograms(); + + let process = IS_OOP ? "extension" : "parent"; + let snapshots = getSnapshots(process); + let keyedSnapshots = getKeyedSnapshots(process); + + for (let id of HISTOGRAM_IDS) { + ok(!(id in snapshots), `No data recorded for histogram: ${id}.`); + } + + for (let id of KEYED_HISTOGRAM_IDS) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id] || {}), + [], + `No data recorded for histogram: ${id}.` + ); + } + + await extension1.startup(); + await extension1.awaitMessage("backgroundDone"); + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, 1); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID1, 1); + } + + // Telemetry from extension1's background page should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + 1, + `Data recorded for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]), + [EXTENSION_ID1], + `Data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID1].values), + 1, + `Data recorded for histogram: ${id}.` + ); + } + + await extension2.startup(); + await extension2.awaitMessage("backgroundDone"); + + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, 2); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID2, 1); + } + + // Telemetry from extension2's background page should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + 2, + `Additional data recorded for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]).sort(), + [EXTENSION_ID1, EXTENSION_ID2], + `Additional data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID2].values), + 1, + `Additional data recorded for histogram: ${id}.` + ); + } + + await extension2.unload(); + + // Run a content script. + process = IS_OOP ? "content" : "parent"; + let expectedCount = IS_OOP ? 1 : 3; + let expectedKeyedCount = IS_OOP ? 1 : 2; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension1.awaitMessage("contentDone"); + + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, expectedCount); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded( + id, + process, + EXTENSION_ID1, + expectedKeyedCount + ); + } + + // Telemetry from extension1's content script should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + expectedCount, + `Data recorded in content script for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]).sort(), + IS_OOP ? [EXTENSION_ID1] : [EXTENSION_ID1, EXTENSION_ID2], + `Additional data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID1].values), + expectedKeyedCount, + `Additional data recorded for histogram: ${id}.` + ); + } + + await extension1.unload(); + + // Telemetry for histograms that we expect to be empty. + for (let id of expectedEmptyHistograms) { + ok(!(id in snapshots), `No data recorded for histogram: ${id}.`); + } + + for (let id of expectedEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id] || {}), + [], + `No data recorded for histogram: ${id}.` + ); + } + + await contentPage.close(); +} + +add_task(async function setup() { + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + // Telemetry test setup needed to ensure that the builtin events are defined + // and they can be collected and verified. + await TelemetryController.testSetup(); + + // This is actually only needed on Android, because it does not properly support unified telemetry + // and so, if not enabled explicitly here, it would make these tests to fail when running on a + // non-Nightly build. + const oldCanRecordBase = Services.telemetry.canRecordBase; + Services.telemetry.canRecordBase = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordBase = oldCanRecordBase; + }); +}); + +add_task(function test_telemetry_background_file_backend() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_telemetry_background + ); +}); + +add_task(function test_telemetry_background_idb_backend() { + return runWithPrefs( + [ + [ExtensionStorageIDB.BACKEND_ENABLED_PREF, true], + // Set the migrated preference for the two test extension, because the + // first storage.local call fallbacks to run in the parent process when we + // don't know which is the selected backend during the extension startup + // and so we can't choose the telemetry histogram to use. + [ + `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID1}`, + true, + ], + [ + `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID2}`, + true, + ], + ], + test_telemetry_background + ); +}); + +// This test verifies that we do record the expected telemetry event when we +// normalize the error message for an unexpected error (an error raised internally +// by the QuotaManager and/or IndexedDB, which it is being normalized into the generic +// "An unexpected error occurred" error message). +add_task(async function test_telemetry_storage_local_unexpected_error() { + // Clear any telemetry events collected so far. + Services.telemetry.clearEvents(); + + const methods = ["clear", "get", "remove", "set"]; + const veryLongErrorName = `VeryLongErrorName${Array(200) + .fill(0) + .join("")}`; + const otherError = new Error("an error recorded as OtherError"); + + const recordedErrors = [ + new DOMException("error message", "UnexpectedDOMException"), + new DOMException("error message", veryLongErrorName), + otherError, + ]; + + // We expect the following errors to not be recorded in telemetry (because they + // are raised on scenarios that we already expect). + const nonRecordedErrors = [ + new DOMException("error message", "QuotaExceededError"), + new DOMException("error message", "DataCloneError"), + ]; + + const expectedEvents = []; + + const errors = [].concat(recordedErrors, nonRecordedErrors); + + for (let i = 0; i < errors.length; i++) { + const error = errors[i]; + const storageMethod = methods[i] || "set"; + ExtensionStorageIDB.normalizeStorageError({ + error: errors[i], + extensionId: EXTENSION_ID1, + storageMethod, + }); + + if (recordedErrors.includes(error)) { + let error_name = + error === otherError ? "OtherError" : getTrimmedString(error.name); + + expectedEvents.push({ + value: EXTENSION_ID1, + object: storageMethod, + extra: { error_name }, + }); + } + } + + await TelemetryTestUtils.assertEvents(expectedEvents, { + category: "extensions.data", + method: "storageLocalError", + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js new file mode 100644 index 0000000000..ccbfddf6d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js @@ -0,0 +1,98 @@ +"use strict"; + +add_task(async function test_extension_page_tabs_create_reload_and_close() { + let events = []; + { + let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("tab-url", browser.runtime.getURL("page.html")); + }, + files: { + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + "page.js"() { + browser.test.sendMessage("extension page loaded", document.URL); + }, + }, + }); + + await extension.startup(); + let tabURL = await extension.awaitMessage("tab-url"); + events.splice(0); + + let contentPage = await ExtensionTestUtils.loadContentPage(tabURL, { + extension, + }); + let extensionPageURL = await extension.awaitMessage("extension page loaded"); + equal(extensionPageURL, tabURL, "Loaded the expected URL"); + + let contextEvents = events.splice(0); + equal(contextEvents.length, 1, "ExtensionContext change for opening a tab"); + equal(contextEvents[0].eventType, "load", "create ExtensionContext for tab"); + equal( + contextEvents[0].url, + extensionPageURL, + "ExtensionContext URL after tab creation should be tab URL" + ); + + await contentPage.spawn(null, () => { + this.content.location.reload(); + }); + let extensionPageURL2 = await extension.awaitMessage("extension page loaded"); + + equal( + extensionPageURL, + extensionPageURL2, + "The tab's URL is expected to not change after a page reload" + ); + + contextEvents = events.splice(0); + equal(contextEvents.length, 2, "ExtensionContext change after tab reload"); + equal(contextEvents[0].eventType, "unload", "unload old ExtensionContext"); + equal( + contextEvents[0].url, + extensionPageURL, + "ExtensionContext URL before reload should be tab URL" + ); + equal( + contextEvents[1].eventType, + "load", + "create new ExtensionContext for tab" + ); + equal( + contextEvents[1].url, + extensionPageURL2, + "ExtensionContext URL after reload should be tab URL" + ); + + await contentPage.close(); + + contextEvents = events.splice(0); + equal(contextEvents.length, 1, "ExtensionContext after closing tab"); + equal(contextEvents[0].eventType, "unload", "unload tab's ExtensionContext"); + equal( + contextEvents[0].url, + extensionPageURL2, + "ExtensionContext URL at closing tab should be tab URL" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js new file mode 100644 index 0000000000..8aa22f5a10 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js @@ -0,0 +1,870 @@ +"use strict"; + +const { TelemetryArchive } = ChromeUtils.import( + "resource://gre/modules/TelemetryArchive.jsm" +); +const { TelemetryUtils } = ChromeUtils.import( + "resource://gre/modules/TelemetryUtils.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +const { TelemetryArchiveTesting } = ChromeUtils.import( + "resource://testing-common/TelemetryArchiveTesting.jsm" +); + +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +// All tests run privileged unless otherwise specified not to. +function createExtension( + backgroundScript, + permissions, + isPrivileged = true, + telemetry +) { + let extensionData = { + background: backgroundScript, + manifest: { permissions, telemetry }, + isPrivileged, + }; + + return ExtensionTestUtils.loadExtension(extensionData); +} + +async function run(test) { + let extension = createExtension( + test.backgroundScript, + test.permissions || ["telemetry"], + test.isPrivileged, + test.telemetry + ); + await extension.startup(); + await extension.awaitFinish(test.doneSignal); + await extension.unload(); +} + +// Currently unsupported on Android: blocked on 1220177. +// See 1280234 c67 for discussion. +if (AppConstants.MOZ_BUILD_APP === "browser") { + add_task(async function test_telemetry_without_telemetry_permission() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.telemetry, + "'telemetry' permission is required" + ); + browser.test.notifyPass("telemetry_permission"); + }, + permissions: [], + doneSignal: "telemetry_permission", + isPrivileged: false, + }); + }); + + add_task( + async function test_telemetry_without_telemetry_permission_privileged() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.telemetry, + "'telemetry' permission is required" + ); + browser.test.notifyPass("telemetry_permission"); + }, + permissions: [], + doneSignal: "telemetry_permission", + }); + } + ); + + add_task(async function test_telemetry_scalar_add() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd( + "telemetry.test.unsigned_int_kind", + 1 + ); + browser.test.notifyPass("scalar_add"); + }, + doneSignal: "scalar_add", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.unsigned_int_kind", + 1 + ); + }); + + add_task(async function test_telemetry_scalar_add_unknown_name() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd("telemetry.test.does_not_exist", 1); + browser.test.notifyPass("scalar_add_unknown_name"); + }, + doneSignal: "scalar_add_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is incremented" + ); + }); + + add_task(async function test_telemetry_scalar_add_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.scalarAdd("telemetry.test.unsigned_int_kind", {}), + /Incorrect argument types for telemetry.scalarAdd/, + "The second 'value' argument to scalarAdd must be an integer, string, or boolean" + ); + browser.test.notifyPass("scalar_add_illegal_value"); + }, + doneSignal: "scalar_add_illegal_value", + }); + }); + + add_task(async function test_telemetry_scalar_add_invalid_keyed_scalar() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd( + "telemetry.test.keyed_unsigned_int", + 1 + ); + browser.test.notifyPass("scalar_add_invalid_keyed_scalar"); + }, + doneSignal: "scalar_add_invalid_keyed_scalar", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("Attempting to manage a keyed scalar as a scalar") + ), + "Telemetry should warn if a scalarAdd is called for a keyed scalar" + ); + }); + + add_task(async function test_telemetry_scalar_set() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet("telemetry.test.boolean_kind", true); + browser.test.notifyPass("scalar_set"); + }, + doneSignal: "scalar_set", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.boolean_kind", + true + ); + }); + + add_task(async function test_telemetry_scalar_set_unknown_name() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet( + "telemetry.test.does_not_exist", + true + ); + browser.test.notifyPass("scalar_set_unknown_name"); + }, + doneSignal: "scalar_set_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is set" + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSetMaximum( + "telemetry.test.unsigned_int_kind", + 123 + ); + browser.test.notifyPass("scalar_set_maximum"); + }, + doneSignal: "scalar_set_maximum", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.unsigned_int_kind", + 123 + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum_unknown_name() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSetMaximum( + "telemetry.test.does_not_exist", + 1 + ); + browser.test.notifyPass("scalar_set_maximum_unknown_name"); + }, + doneSignal: "scalar_set_maximum_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is set" + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.scalarSetMaximum( + "telemetry.test.unsigned_int_kind", + "string" + ), + /Incorrect argument types for telemetry.scalarSetMaximum/, + "The second 'value' argument to scalarSetMaximum must be a scalar" + ); + browser.test.notifyPass("scalar_set_maximum_illegal_value"); + }, + doneSignal: "scalar_set_maximum_illegal_value", + }); + }); + + add_task(async function test_telemetry_keyed_scalar_add() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add"); + }, + doneSignal: "keyed_scalar_add", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_unsigned_int", + "foo", + 1 + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_unknown_name() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.does_not_exist", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add_unknown_name"); + }, + doneSignal: "keyed_scalar_add_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is incremented" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "foo", + {} + ), + /Incorrect argument types for telemetry.keyedScalarAdd/, + "The second 'value' argument to keyedScalarAdd must be an integer, string, or boolean" + ); + browser.test.notifyPass("keyed_scalar_add_illegal_value"); + }, + doneSignal: "keyed_scalar_add_illegal_value", + }); + }); + + add_task(async function test_telemetry_keyed_scalar_add_invalid_scalar() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.unsigned_int_kind", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add_invalid_scalar"); + }, + doneSignal: "keyed_scalar_add_invalid_scalar", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes( + "Attempting to manage a keyed scalar as a scalar (or vice-versa)" + ) + ), + "Telemetry should warn if a scalar is incremented as a keyed scalar" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_add_long_key"); + }, + doneSignal: "keyed_scalar_add_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters.") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.keyed_boolean_kind", + "foo", + true + ); + browser.test.notifyPass("keyed_scalar_set"); + }, + doneSignal: "keyed_scalar_set", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_boolean_kind", + "foo", + true + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_unknown_name() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.does_not_exist", + "foo", + true + ); + browser.test.notifyPass("keyed_scalar_set_unknown_name"); + }, + doneSignal: "keyed_scalar_set_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is incremented" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_set_long_key"); + }, + doneSignal: "keyed_scalar_set_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_maximum() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "foo", + 123 + ); + browser.test.notifyPass("keyed_scalar_set_maximum"); + }, + doneSignal: "keyed_scalar_set_maximum", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_unsigned_int", + "foo", + 123 + ); + }); + + add_task( + async function test_telemetry_keyed_scalar_set_maximum_unknown_name() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.does_not_exist", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_set_maximum_unknown_name"); + }, + doneSignal: "keyed_scalar_set_maximum_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is set" + ); + } + ); + + add_task( + async function test_telemetry_keyed_scalar_set_maximum_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "foo", + "string" + ), + /Incorrect argument types for telemetry.keyedScalarSetMaximum/, + "The third 'value' argument to keyedScalarSetMaximum must be a scalar" + ); + browser.test.notifyPass("keyed_scalar_set_maximum_illegal_value"); + }, + doneSignal: "keyed_scalar_set_maximum_illegal_value", + }); + } + ); + + add_task(async function test_telemetry_keyed_scalar_set_maximum_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_set_maximum_long_key"); + }, + doneSignal: "keyed_scalar_set_maximum_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_record_event() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("telemetry.test", true); + + await run({ + backgroundScript: async () => { + await browser.telemetry.recordEvent( + "telemetry.test", + "test1", + "object1" + ); + browser.test.notifyPass("record_event_ok"); + }, + doneSignal: "record_event_ok", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test", + method: "test1", + object: "object1", + }, + ], + { category: "telemetry.test" } + ); + + Services.telemetry.setEventRecordingEnabled("telemetry.test", false); + Services.telemetry.clearEvents(); + }); + + // Bug 1536877 + add_task(async function test_telemetry_record_event_value_must_be_string() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("telemetry.test", true); + + await run({ + backgroundScript: async () => { + try { + await browser.telemetry.recordEvent( + "telemetry.test", + "test1", + "object1", + "value1" + ); + browser.test.notifyPass("record_event_string_value"); + } catch (ex) { + browser.test.fail( + `Unexpected exception raised during record_event_value_must_be_string: ${ex}` + ); + browser.test.notifyPass("record_event_string_value"); + throw ex; + } + }, + doneSignal: "record_event_string_value", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test", + method: "test1", + object: "object1", + value: "value1", + }, + ], + { category: "telemetry.test" } + ); + + Services.telemetry.setEventRecordingEnabled("telemetry.test", false); + Services.telemetry.clearEvents(); + }); + + add_task(async function test_telemetry_register_scalars_string() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_string: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string", + "hello" + ); + browser.test.notifyPass("register_scalars_string"); + }, + doneSignal: "register_scalars_string", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.dynamic.webext_string", + "hello" + ); + }); + + add_task(async function test_telemetry_register_scalars_multiple() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_string: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + webext_string_too: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string", + "hello" + ); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string_too", + "world" + ); + browser.test.notifyPass("register_scalars_multiple"); + }, + doneSignal: "register_scalars_multiple", + }); + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar( + scalars, + "telemetry.test.dynamic.webext_string", + "hello" + ); + TelemetryTestUtils.assertScalar( + scalars, + "telemetry.test.dynamic.webext_string_too", + "world" + ); + }); + + add_task(async function test_telemetry_register_scalars_boolean() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_boolean: { + kind: browser.telemetry.ScalarType.BOOLEAN, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_boolean", + true + ); + browser.test.notifyPass("register_scalars_boolean"); + }, + doneSignal: "register_scalars_boolean", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("dynamic", false, true), + "telemetry.test.dynamic.webext_boolean", + true + ); + }); + + add_task(async function test_telemetry_register_scalars_count() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_count: { + kind: browser.telemetry.ScalarType.COUNT, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_count", + 123 + ); + browser.test.notifyPass("register_scalars_count"); + }, + doneSignal: "register_scalars_count", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("dynamic", false, true), + "telemetry.test.dynamic.webext_count", + 123 + ); + }); + + add_task(async function test_telemetry_register_events() { + Services.telemetry.clearEvents(); + + await run({ + backgroundScript: async () => { + await browser.telemetry.registerEvents("telemetry.test.dynamic", { + test1: { + methods: ["test1"], + objects: ["object1"], + extra_keys: [], + }, + }); + await browser.telemetry.recordEvent( + "telemetry.test.dynamic", + "test1", + "object1" + ); + browser.test.notifyPass("register_events"); + }, + doneSignal: "register_events", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test.dynamic", + method: "test1", + object: "object1", + }, + ], + { category: "telemetry.test.dynamic" }, + { process: "dynamic" } + ); + }); + + add_task(async function test_telemetry_submit_ping() { + let archiveTester = new TelemetryArchiveTesting.Checker(); + await archiveTester.promiseInit(); + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitPing("webext-test", {}, {}); + browser.test.notifyPass("submit_ping"); + }, + doneSignal: "submit_ping", + }); + + await TestUtils.waitForCondition( + () => archiveTester.promiseFindPing("webext-test", []), + "Failed to find the webext-test ping" + ); + }); + + add_task(async function test_telemetry_submit_encrypted_ping() { + await run({ + backgroundScript: async () => { + try { + await browser.telemetry.submitEncryptedPing( + { payload: "encrypted-webext-test" }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.fail( + "Expected exception without required manifest entries set." + ); + } catch (e) { + browser.test.assertTrue( + e, + /Encrypted telemetry pings require ping_type and public_key to be set in manifest./ + ); + browser.test.notifyPass("submit_encrypted_ping_fail"); + } + }, + doneSignal: "submit_encrypted_ping_fail", + }); + + const telemetryManifestEntries = { + ping_type: "encrypted-webext-ping", + schemaNamespace: "schema-namespace", + public_key: { + id: "pioneer-dev-20200423", + key: { + crv: "P-256", + kty: "EC", + x: "Qqihp7EryDN2-qQ-zuDPDpy5mJD5soFBDZmzPWTmjwk", + y: "PiEQVUlywi2bEsA3_5D0VFrCHClCyUlLW52ajYs-5uc", + }, + }, + }; + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitEncryptedPing( + { + payload: "encrypted-webext-test", + }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.notifyPass("submit_encrypted_ping_pass"); + }, + permissions: ["telemetry"], + doneSignal: "submit_encrypted_ping_pass", + isPrivileged: true, + telemetry: telemetryManifestEntries, + }); + + telemetryManifestEntries.pioneer_id = true; + telemetryManifestEntries.study_name = "test123"; + Services.prefs.setStringPref("toolkit.telemetry.pioneerId", "test123"); + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitEncryptedPing( + { payload: "encrypted-webext-test" }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.notifyPass("submit_encrypted_ping_pass"); + }, + permissions: ["telemetry"], + doneSignal: "submit_encrypted_ping_pass", + isPrivileged: true, + telemetry: telemetryManifestEntries, + }); + + let pings; + await TestUtils.waitForCondition(async function() { + pings = await TelemetryArchive.promiseArchivedPingList(); + return pings.length >= 3; + }, "Wait until we have at least 3 pings in the telemetry archive"); + + equal(pings.length, 3); + equal(pings[1].type, "encrypted-webext-ping"); + equal(pings[2].type, "encrypted-webext-ping"); + }); + + add_task(async function test_telemetry_can_upload_enabled() { + Services.prefs.setBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + true + ); + + await run({ + backgroundScript: async () => { + const result = await browser.telemetry.canUpload(); + browser.test.assertTrue(result); + browser.test.notifyPass("can_upload_enabled"); + }, + doneSignal: "can_upload_enabled", + }); + + Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled); + }); + + add_task(async function test_telemetry_can_upload_disabled() { + Services.prefs.setBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + false + ); + + await run({ + backgroundScript: async () => { + const result = await browser.telemetry.canUpload(); + browser.test.assertFalse(result); + browser.test.notifyPass("can_upload_disabled"); + }, + doneSignal: "can_upload_disabled", + }); + + Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js new file mode 100644 index 0000000000..81e07d9a9b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js @@ -0,0 +1,55 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test verifies that the extension mocks behave consistently, regardless +// of test type (xpcshell vs browser test). +// See also toolkit/components/extensions/test/browser/browser_ext_test_mock.js + +// Check the state of the extension object. This should be consistent between +// browser tests and xpcshell tests. +async function checkExtensionStartupAndUnload(ext) { + await ext.startup(); + Assert.ok(ext.id, "Extension ID should be available"); + Assert.ok(ext.uuid, "Extension UUID should be available"); + await ext.unload(); + // Once set nothing clears the UUID. + Assert.ok(ext.uuid, "Extension UUID exists after unload"); +} + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_MockExtension() { + let ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }); + + Assert.equal(ext.constructor.name, "InstallableWrapper", "expected class"); + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + // When useAddonManager is set, AOMExtensionWrapper clears the ID upon unload. + // TODO: Fix AOMExtensionWrapper to not clear the ID after unload, and move + // this assertion inside |checkExtensionStartupAndUnload| (since then the + // behavior will be consistent across all test types). + Assert.ok(!ext.id, "Extension ID is cleared after unload"); +}); + +add_task(async function test_generated_Extension() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: {}, + }); + + Assert.equal(ext.constructor.name, "ExtensionWrapper", "expected class"); + // Without "useAddonManager", an Extension is generated and their IDs are + // immediately available. + Assert.ok(ext.id, "Extension ID is initially available"); + Assert.ok(ext.uuid, "Extension UUID is initially available"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js new file mode 100644 index 0000000000..1752b5a2b5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js @@ -0,0 +1,64 @@ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +// Automatically start the background page after restarting the AddonManager. +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const TEST_ADDON_ID = "@some-permanent-test-addon"; + +// Load a permanent extension that eventually unloads the extension immediately +// after add-on startup, to set the stage as a regression test for bug 1575190. +add_task(async function setup_wrapper() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: TEST_ADDON_ID } }, + }, + background() { + browser.test.sendMessage("started_up"); + }, + }); + + await AddonTestUtils.promiseStartupManager(); + await extension.startup(); + await AddonTestUtils.promiseShutdownManager(); + + // Check message because it is expected to be received while `startup()` was + // pending resolution. + info("Awaiting expected started_up message 1"); + await extension.awaitMessage("started_up"); + + // Load AddonManager, and unload the extension as soon as it has started. + await AddonTestUtils.promiseStartupManager(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + + // Confirm that the extension has started when promiseStartupManager returned. + info("Awaiting expected started_up message 2"); + await extension.awaitMessage("started_up"); +}); + +// Check that the add-on from the previous test has indeed been uninstalled. +add_task(async function restart_addon_manager_after_extension_unload() { + await AddonTestUtils.promiseStartupManager(); + let addon = await AddonManager.getAddonByID(TEST_ADDON_ID); + equal(addon, null, "Test add-on should have been removed"); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js new file mode 100644 index 0000000000..f509ae1749 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js @@ -0,0 +1,20 @@ +"use strict"; + +/** + * This test is asserting that moz-extension: URLs are recognized as trustworthy local origins + */ + +add_task( + function test_isOriginPotentiallyTrustworthnsIContentSecurityManagery() { + let uri = NetUtil.newURI("moz-extension://foobar/something.html"); + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + Assert.equal( + principal.isOriginPotentiallyTrustworthy, + true, + "it is potentially trustworthy" + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js new file mode 100644 index 0000000000..9c515520e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js @@ -0,0 +1,63 @@ +"use strict"; + +// This test expects and checks warnings for unknown permissions. +ExtensionTestUtils.failOnSchemaWarnings(false); + +add_task(async function test_unknown_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "activeTab", + "fooUnknownPermission", + "http://*/", + "chrome://favicon/", + ], + optional_permissions: ["chrome://favicon/", "https://example.com/"], + }, + }); + + let { messages } = await promiseConsoleOutput(() => extension.startup()); + + const { WebExtensionPolicy } = Cu.getGlobalForObject( + ChromeUtils.import("resource://gre/modules/Extension.jsm", {}) + ); + + let policy = WebExtensionPolicy.getByID(extension.id); + Assert.deepEqual(Array.from(policy.permissions).sort(), [ + "activeTab", + "http://*/*", + ]); + + Assert.deepEqual(extension.extension.manifest.optional_permissions, [ + "https://example.com/", + ]); + + ok( + messages.some(message => + /Error processing permissions\.1: Value "fooUnknownPermission" must/.test( + message + ) + ), + 'Got expected error for "fooUnknownPermission"' + ); + + ok( + messages.some(message => + /Error processing permissions\.3: Value "chrome:\/\/favicon\/" must/.test( + message + ) + ), + 'Got expected error for "chrome://favicon/"' + ); + + ok( + messages.some(message => + /Error processing optional_permissions\.0: Value "chrome:\/\/favicon\/" must/.test( + message + ) + ), + "Got expected error from optional_permissions" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js new file mode 100644 index 0000000000..6a9125c9e4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js @@ -0,0 +1,213 @@ +"use strict"; + +const { + createAppInfo, + promiseStartupManager, + promiseRestartManager, + promiseWebExtensionStartup, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +const STORAGE_SITE_PERMISSIONS = [ + "WebExtensions-unlimitedStorage", + "indexedDB", + "persistent-storage", +]; + +function checkSitePermissions(principal, expectedPermAction, assertMessage) { + for (const permName of STORAGE_SITE_PERMISSIONS) { + const actualPermAction = Services.perms.testPermissionFromPrincipal( + principal, + permName + ); + + equal( + actualPermAction, + expectedPermAction, + `The extension "${permName}" SitePermission ${assertMessage} as expected` + ); + } +} + +add_task(async function test_unlimitedStorage_restored_on_app_startup() { + const id = "test-unlimitedStorage-removed-on-app-shutdown@mozilla"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unlimitedStorage"], + applications: { + gecko: { id }, + }, + }, + + useAddonManager: "permanent", + }); + + await promiseStartupManager(); + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + const principal = policy.extension.principal; + + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + // Remove site permissions as it would happen if Firefox is shutting down + // with the "clear site permissions" setting. + + Services.perms.removeFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ); + Services.perms.removeFromPrincipal(principal, "indexedDB"); + Services.perms.removeFromPrincipal(principal, "persistent-storage"); + + checkSitePermissions(principal, Services.perms.UNKNOWN_ACTION, "is not set"); + + const onceExtensionStarted = promiseWebExtensionStartup(id); + await promiseRestartManager(); + await onceExtensionStarted; + + // The site permissions should have been granted again. + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + await extension.unload(); +}); + +add_task(async function test_unlimitedStorage_removed_on_update() { + const id = "test-unlimitedStorage-removed-on-update@mozilla"; + + function background() { + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "set-storage": + browser.test.log(`storing data in storage.local`); + await browser.storage.local.set({ akey: "somevalue" }); + browser.test.log(`data stored in storage.local successfully`); + break; + case "has-storage": { + browser.test.log(`checking data stored in storage.local`); + const data = await browser.storage.local.get(["akey"]); + browser.test.assertEq( + data.akey, + "somevalue", + "Got storage.local data" + ); + break; + } + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["unlimitedStorage", "storage"], + applications: { gecko: { id } }, + version: "1", + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + const principal = policy.extension.principal; + + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + extension.sendMessage("set-storage"); + await extension.awaitMessage("set-storage:done"); + extension.sendMessage("has-storage"); + await extension.awaitMessage("has-storage:done"); + + // Simulate an update which do not require the unlimitedStorage permission. + await extension.upgrade({ + background, + manifest: { + permissions: ["storage"], + applications: { gecko: { id } }, + version: "2", + }, + useAddonManager: "permanent", + }); + + const newPolicy = WebExtensionPolicy.getByID(extension.id); + const newPrincipal = newPolicy.extension.principal; + + equal( + principal.spec, + newPrincipal.spec, + "upgraded extension has the expected principal" + ); + + checkSitePermissions( + principal, + Services.perms.UNKNOWN_ACTION, + "has been cleared" + ); + + // Verify that the previously stored data has not been + // removed as a side effect of removing the unlimitedStorage + // permission. + extension.sendMessage("has-storage"); + await extension.awaitMessage("has-storage:done"); + + await extension.unload(); +}); + +add_task(async function test_unlimitedStorage_origin_attributes() { + Services.prefs.setBoolPref("privacy.firstparty.isolate", true); + + const id = "test-unlimitedStorage-origin-attributes@mozilla"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unlimitedStorage"], + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + + ok( + !principal.firstPartyDomain, + "extension principal has no firstPartyDomain" + ); + + let perm = Services.perms.testExactPermissionFromPrincipal( + principal, + "persistent-storage" + ); + equal( + perm, + Services.perms.ALLOW_ACTION, + "Should have the correct permission without OAs" + ); + + await extension.unload(); + + Services.prefs.clearUserPref("privacy.firstparty.isolate"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js new file mode 100644 index 0000000000..058e8b7371 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js @@ -0,0 +1,230 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +// Background and content script for testSendMessage_* +function sendMessage_background(delayedNotifyPass) { + browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { + browser.test.assertEq("from frame", msg, "Expected message from frame"); + sendResponse("msg from back"); // Should not throw or anything like that. + delayedNotifyPass("Received sendMessage from closing frame"); + }); +} +function sendMessage_contentScript(testType) { + browser.runtime.sendMessage("from frame", reply => { + // The frame has been removed, so we should not get this callback! + browser.test.fail(`Unexpected reply: ${reply}`); + }); + if (testType == "frame") { + frameElement.remove(); + } else { + browser.test.sendMessage("close-window"); + } +} + +// Background and content script for testConnect_* +function connect_background(delayedNotifyPass) { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("port from frame", port.name); + + let disconnected = false; + let hasMessage = false; + port.onDisconnect.addListener(() => { + browser.test.assertFalse(disconnected, "onDisconnect should fire once"); + disconnected = true; + browser.test.assertTrue( + hasMessage, + "Expected onMessage before onDisconnect" + ); + browser.test.assertEq( + null, + port.error, + "The port is implicitly closed without errors when the other context unloads" + ); + delayedNotifyPass("Received onDisconnect from closing frame"); + }); + port.onMessage.addListener(msg => { + browser.test.assertFalse(hasMessage, "onMessage should fire once"); + hasMessage = true; + browser.test.assertFalse( + disconnected, + "Should get message before disconnect" + ); + browser.test.assertEq("from frame", msg, "Expected message from frame"); + }); + + port.postMessage("reply to closing frame"); + }); +} +function connect_contentScript(testType) { + let isUnloading = false; + addEventListener( + "pagehide", + () => { + isUnloading = true; + }, + { once: true } + ); + + let port = browser.runtime.connect({ name: "port from frame" }); + port.onMessage.addListener(msg => { + // The background page sends a reply as soon as we call runtime.connect(). + // It is possible that the reply reaches this frame before the + // window.close() request has been processed. + if (!isUnloading) { + browser.test.log( + `Ignorting unexpected reply ("${msg}") because the page is not being unloaded.` + ); + return; + } + + // The frame has been removed, so we should not get a reply. + browser.test.fail(`Unexpected reply: ${msg}`); + }); + port.postMessage("from frame"); + + // Removing the frame or window should disconnect the port. + if (testType == "frame") { + frameElement.remove(); + } else { + browser.test.sendMessage("close-window"); + } +} + +// `testType` is "window" or "frame". +function createTestExtension(testType, backgroundScript, contentScript) { + // Make a roundtrip between the background page and the test runner (which is + // in the same process as the content script) to make sure that we record a + // failure in case the content script's sendMessage or onMessage handlers are + // called even after the frame or window was removed. + function delayedNotifyPass(msg) { + browser.test.onMessage.addListener((type, echoMsg) => { + if (type == "pong") { + browser.test.assertEq(msg, echoMsg, "Echoed reply should be the same"); + browser.test.notifyPass(msg); + } + }); + browser.test.log("Starting ping-pong to flush messages..."); + browser.test.sendMessage("ping", msg); + } + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})(${delayedNotifyPass});`, + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + all_frames: testType == "frame", + matches: ["http://example.com/data/file_sample.html"], + }, + ], + }, + files: { + "contentscript.js": `(${contentScript})("${testType}");`, + }, + }); + extension.awaitMessage("ping").then(msg => { + extension.sendMessage("pong", msg); + }); + return extension; +} + +add_task(async function testSendMessage_and_remove_frame() { + let extension = createTestExtension( + "frame", + sendMessage_background, + sendMessage_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await contentPage.spawn(null, () => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = "/data/file_sample.html"; + document.body.appendChild(frame); + }); + + await extension.awaitFinish("Received sendMessage from closing frame"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function testConnect_and_remove_frame() { + let extension = createTestExtension( + "frame", + connect_background, + connect_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await contentPage.spawn(null, () => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = "/data/file_sample.html"; + document.body.appendChild(frame); + }); + + await extension.awaitFinish("Received onDisconnect from closing frame"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function testSendMessage_and_remove_window() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // We can't rely on this timing on Android. + return; + } + + let extension = createTestExtension( + "window", + sendMessage_background, + sendMessage_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("close-window"); + await contentPage.close(); + + await extension.awaitFinish("Received sendMessage from closing frame"); + await extension.unload(); +}); + +add_task(async function testConnect_and_remove_window() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // We can't rely on this timing on Android. + return; + } + + let extension = createTestExtension( + "window", + connect_background, + connect_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("close-window"); + await contentPage.close(); + + await extension.awaitFinish("Received onDisconnect from closing frame"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js new file mode 100644 index 0000000000..78114d9de4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js @@ -0,0 +1,671 @@ +"use strict"; + +const PROCESS_COUNT_PREF = "dom.ipc.processCount"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function setup_test_environment() { + if (ExtensionTestUtils.remoteContentScripts) { + // Start with one content process so that we can increase the number + // later and test the behavior of a fresh content process. + Services.prefs.setIntPref(PROCESS_COUNT_PREF, 1); + } + + // Grant the optional permissions requested. + function permissionObserver(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + let { resolve } = subject.wrappedJSObject; + resolve(true); + } + } + Services.obs.addObserver( + permissionObserver, + "webextension-optional-permission-prompt" + ); + registerCleanupFunction(() => { + Services.obs.removeObserver( + permissionObserver, + "webextension-optional-permission-prompt" + ); + }); +}); + +// Test that there is no userScripts API namespace when the manifest doesn't include a user_scripts +// property. +add_task(async function test_userScripts_manifest_property_required() { + function background() { + browser.test.assertEq( + undefined, + browser.userScripts, + "userScripts API namespace should be undefined in the extension page" + ); + browser.test.sendMessage("background-page:done"); + } + + async function contentScript() { + browser.test.assertEq( + undefined, + browser.userScripts, + "userScripts API namespace should be undefined in the content script" + ); + browser.test.sendMessage("content-script:done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-page:done"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("content-script:done"); + + await extension.unload(); + await contentPage.close(); +}); + +// Test that userScripts can only matches origins that are subsumed by the extension permissions, +// and that more origins can be allowed by requesting an optional permission. +add_task(async function test_userScripts_matches_denied() { + async function background() { + async function registerUserScriptWithMatches(matches) { + const scripts = await browser.userScripts.register({ + js: [{ code: "" }], + matches, + }); + await scripts.unregister(); + } + + // These matches are supposed to be denied until the extension has been granted the + // <all_urls> origin permission. + const testMatches = [ + "<all_urls>", + "file://*/*", + "https://localhost/*", + "http://example.com/*", + ]; + + browser.test.onMessage.addListener(async msg => { + if (msg === "test-denied-matches") { + for (let testMatch of testMatches) { + await browser.test.assertRejects( + registerUserScriptWithMatches([testMatch]), + /Permission denied to register a user script for/, + "Got the expected rejection when the extension permission does not subsume the userScript matches" + ); + } + } else if (msg === "grant-all-urls") { + await browser.permissions.request({ origins: ["<all_urls>"] }); + } else if (msg === "test-allowed-matches") { + for (let testMatch of testMatches) { + try { + await registerUserScriptWithMatches([testMatch]); + } catch (err) { + browser.test.fail( + `Unexpected rejection ${err} on matching ${JSON.stringify( + testMatch + )}` + ); + } + } + } else { + browser.test.fail(`Received an unexpected ${msg} test message`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + + browser.test.sendMessage("background-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*"], + optional_permissions: ["<all_urls>"], + user_scripts: {}, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("background-ready"); + + // Test that the matches not subsumed by the extension permissions are being denied. + extension.sendMessage("test-denied-matches"); + await extension.awaitMessage("test-denied-matches:done"); + + // Grant the optional <all_urls> permission. + await withHandlingUserInput(extension, async () => { + extension.sendMessage("grant-all-urls"); + await extension.awaitMessage("grant-all-urls:done"); + }); + + // Test that all the matches are now subsumed by the extension permissions. + extension.sendMessage("test-allowed-matches"); + await extension.awaitMessage("test-allowed-matches:done"); + + await extension.unload(); +}); + +// Test that userScripts sandboxes: +// - can be registered/unregistered from an extension page (and they are registered on both new and +// existing processes). +// - have no WebExtensions APIs available +// - are able to access the target window and document +add_task(async function test_userScripts_no_webext_apis() { + async function background() { + const matches = ["http://localhost/*/file_sample.html*"]; + + const sharedCode = { + code: 'console.log("js code shared by multiple userScripts");', + }; + + const userScriptOptions = { + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined; + document.body.innerHTML = "userScript loaded - " + JSON.stringify(webextAPINamespaces); + }, {once: true}); + `, + }, + ], + runAt: "document_start", + matches, + scriptMetadata: { + name: "test-user-script", + arrayProperty: ["el1"], + objectProperty: { nestedProp: "nestedValue" }, + nullProperty: null, + }, + }; + + let script = await browser.userScripts.register(userScriptOptions); + + // Unregister and then register the same js code again, to verify that the last registered + // userScript doesn't get assigned a revoked blob url (otherwise Extensioncontent.jsm + // ScriptCache raises an error because it fails to compile the revoked blob url and the user + // script will never be loaded). + script.unregister(); + script = await browser.userScripts.register(userScriptOptions); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "register-new-script") { + return; + } + + await script.unregister(); + await browser.userScripts.register({ + ...userScriptOptions, + scriptMetadata: { name: "test-new-script" }, + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined; + document.body.innerHTML = "new userScript loaded - " + JSON.stringify(webextAPINamespaces); + }, {once: true}); + `, + }, + ], + }); + + browser.test.sendMessage("script-registered"); + }); + + const scriptToRemove = await browser.userScripts.register({ + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + document.body.innerHTML = "unexpected unregistered userScript loaded"; + }, {once: true}); + `, + }, + ], + runAt: "document_start", + matches, + scriptMetadata: { + name: "user-script-to-remove", + }, + }); + + browser.test.assertTrue( + "unregister" in script, + "Got an unregister method on the userScript API object" + ); + + // Remove the last registered user script. + await scriptToRemove.unregister(); + + browser.test.sendMessage("background-ready"); + } + + let extensionData = { + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: {}, + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitMessage("background-ready"); + + let url = `${BASE_URL}/file_sample.html?testpage=1`; + let contentPage = await ExtensionTestUtils.loadContentPage( + url, + ExtensionTestUtils.remoteContentScripts ? { remote: true } : undefined + ); + let result = await contentPage.spawn(undefined, async () => { + return { + textContent: this.content.document.body.textContent, + url: this.content.location.href, + readyState: this.content.document.readyState, + }; + }); + Assert.deepEqual( + result, + { + textContent: "userScript loaded - undefined", + url, + readyState: "complete", + }, + "The userScript executed on the expected url and no access to the WebExtensions APIs" + ); + + // If the tests is running with "remote content process" mode, test that the userScript + // are being correctly registered in newly created processes (received as part of the sharedData). + if (ExtensionTestUtils.remoteContentScripts) { + info( + "Test content script are correctly created on a newly created process" + ); + + await extension.sendMessage("register-new-script"); + await extension.awaitMessage("script-registered"); + + // Update the process count preference, so that we can test that the newly registered user script + // is propagated as expected into the newly created process. + Services.prefs.setIntPref(PROCESS_COUNT_PREF, 2); + + const url2 = `${BASE_URL}/file_sample.html?testpage=2`; + let contentPage2 = await ExtensionTestUtils.loadContentPage(url2, { + remote: true, + }); + let result2 = await contentPage2.spawn(undefined, async () => { + return { + textContent: this.content.document.body.textContent, + url: this.content.location.href, + readyState: this.content.document.readyState, + }; + }); + Assert.deepEqual( + result2, + { + textContent: "new userScript loaded - undefined", + url: url2, + readyState: "complete", + }, + "The userScript executed on the expected url and no access to the WebExtensions APIs" + ); + + await contentPage2.close(); + } + + await contentPage.close(); + + await extension.unload(); +}); + +// This test verify that a cached script is still able to catch the document +// while it is still loading (when we do not block the document parsing as +// we do for a non cached script). +add_task(async function test_cached_userScript_on_document_start() { + function apiScript() { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + sendTestMessage(name, params) { + return browser.test.sendMessage(name, params); + }, + }); + }); + } + + async function background() { + function userScript() { + this.sendTestMessage("user-script-loaded", { + url: window.location.href, + documentReadyState: document.readyState, + }); + } + + await browser.userScripts.register({ + js: [ + { + code: `(${userScript})();`, + }, + ], + runAt: "document_start", + matches: ["http://localhost/*/file_sample.html"], + }); + + browser.test.sendMessage("user-script-registered"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: { + api_script: "api-script.js", + // The following is an unexpected manifest property, that we expect to be ignored and + // to not prevent the test extension from being installed and run as expected. + unexpected_manifest_key: "test-unexpected-key", + }, + }, + background, + files: { + "api-script.js": apiScript, + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("user-script-registered"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let msg = await extension.awaitMessage("user-script-loaded"); + Assert.deepEqual( + msg, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a non cached user script" + ); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(url); + + let msgFromCached = await extension.awaitMessage("user-script-loaded"); + Assert.deepEqual( + msgFromCached, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a cached user script" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_userScripts_pref_disabled() { + async function run_userScript_on_pref_disabled_test() { + async function background() { + let promise = (async () => { + await browser.userScripts.register({ + js: [ + { + code: + "throw new Error('This userScripts should not be registered')", + }, + ], + runAt: "document_start", + matches: ["<all_urls>"], + }); + })(); + + await browser.test.assertRejects( + promise, + /userScripts APIs are currently experimental/, + "Got the expected error from userScripts.register when the userScripts API is disabled" + ); + + browser.test.sendMessage("background-page:done"); + } + + async function contentScript() { + let promise = (async () => { + browser.userScripts.onBeforeScript.addListener(() => {}); + })(); + await browser.test.assertRejects( + promise, + /userScripts APIs are currently experimental/, + "Got the expected error from userScripts.onBeforeScript when the userScripts API is disabled" + ); + + browser.test.sendMessage("content-script:done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { api_script: "" }, + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-page:done"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("content-script:done"); + + await extension.unload(); + await contentPage.close(); + } + + await runWithPrefs( + [["extensions.webextensions.userScripts.enabled", false]], + run_userScript_on_pref_disabled_test + ); +}); + +// This test verify that userScripts.onBeforeScript API Event is not available without +// a "user_scripts.api_script" property in the manifest. +add_task(async function test_user_script_api_script_required() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + user_scripts: {}, + }, + files: { + "content_script.js": function() { + browser.test.assertEq( + undefined, + browser.userScripts && browser.userScripts.onBeforeScript, + "Got an undefined onBeforeScript property as expected" + ); + browser.test.sendMessage("no-onBeforeScript:done"); + }, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("no-onBeforeScript:done"); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_scriptMetaData() { + function getTestCases(isUserScriptsRegister) { + return [ + // When scriptMetadata is not set (or undefined), it is treated as if it were null. + // In the API script, the metadata is then expected to be null. + isUserScriptsRegister ? undefined : null, + + // Falsey + null, + "", + false, + 0, + + // Truthy + true, + 1, + "non-empty string", + + // Objects + ["some array with value"], + { "some object": "with value" }, + ]; + } + + async function background(pageUrl) { + for (let scriptMetadata of getTestCases(true)) { + await browser.userScripts.register({ + js: [{ file: "userscript.js" }], + runAt: "document_end", + allFrames: true, + matches: ["http://localhost/*/file_sample.html"], + scriptMetadata, + }); + } + + let f = document.createElement("iframe"); + f.src = pageUrl; + document.body.append(f); + browser.test.sendMessage("background-page:done"); + } + + function apiScript() { + let testCases = getTestCases(false); + let i = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + checkMetadata() { + let expectation = testCases[i]; + let metadata = script.metadata; + if (typeof expectation === "object" && expectation !== null) { + // Non-primitive values cannot be compared with assertEq, + // so serialize both and just verify that they are equal. + expectation = JSON.stringify(expectation); + metadata = JSON.stringify(script.metadata); + } + + browser.test.assertEq( + expectation, + metadata, + `Expected metadata at call ${i}` + ); + if (++i === testCases.length) { + browser.test.sendMessage("apiscript:done"); + } + }, + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `${getTestCases};(${background})("${BASE_URL}/file_sample.html")`, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { + api_script: "apiscript.js", + }, + }, + files: { + "apiscript.js": `${getTestCases};(${apiScript})()`, + "userscript.js": "checkMetadata();", + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-page:done"); + await extension.awaitMessage("apiscript:done"); + + await extension.unload(); +}); + +add_task(async function test_userScriptOptions_js_property_required() { + function background() { + const userScriptOptions = { + runAt: "document_start", + matches: ["http://*/*/file_sample.html"], + }; + + browser.test.assertThrows( + () => browser.userScripts.register(userScriptOptions), + /Type error for parameter userScriptOptions \(Property \"js\" is required\)/, + "Got the expected error from userScripts.register when js property is missing" + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: {}, + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js new file mode 100644 index 0000000000..72e8a51c7f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js @@ -0,0 +1,1108 @@ +"use strict"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// A small utility function used to test the expected behaviors of the userScripts API method +// wrapper. +async function test_userScript_APIMethod({ + apiScript, + userScript, + userScriptMetadata, + testFn, + runtimeMessageListener, +}) { + async function backgroundScript( + userScriptFn, + scriptMetadata, + messageListener + ) { + await browser.userScripts.register({ + js: [ + { + code: `(${userScriptFn})();`, + }, + ], + runAt: "document_end", + matches: ["http://localhost/*/file_sample.html"], + scriptMetadata, + }); + + if (messageListener) { + browser.runtime.onMessage.addListener(messageListener); + } + + browser.test.sendMessage("background-ready"); + } + + function notifyFinish(failureReason) { + browser.test.assertEq( + undefined, + failureReason, + "should be completed without errors" + ); + browser.test.sendMessage("test_userScript_APIMethod:done"); + } + + function assertTrue(val, message) { + browser.test.assertTrue(val, message); + if (!val) { + browser.test.sendMessage("test_userScript_APIMethod:done"); + throw message; + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: { + api_script: "api-script.js", + }, + }, + // Defines a background script that receives all the needed test parameters. + background: ` + const metadata = ${JSON.stringify(userScriptMetadata)}; + (${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener}) + `, + files: { + "api-script.js": `(${apiScript})({ + assertTrue: ${assertTrue}, + notifyFinish: ${notifyFinish} + })`, + }, + }); + + // Load a page in a content process, register the user script and then load a + // new page in the existing content process. + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + await contentPage.loadURL(url); + + // Run any additional test-specific assertions. + if (testFn) { + await testFn({ extension, contentPage, url }); + } + + await extension.awaitMessage("test_userScript_APIMethod:done"); + + await extension.unload(); + await contentPage.close(); +} + +add_task(async function test_apiScript_exports_simple_sync_method() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + const scriptMetadata = script.metadata; + + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod( + stringParam, + numberParam, + boolParam, + nullParam, + undefinedParam, + arrayParam + ) { + browser.test.assertEq( + "test-user-script-exported-apis", + scriptMetadata.name, + "Got the expected value for a string scriptMetadata property" + ); + browser.test.assertEq( + null, + scriptMetadata.nullProperty, + "Got the expected value for a null scriptMetadata property" + ); + browser.test.assertTrue( + scriptMetadata.arrayProperty && + scriptMetadata.arrayProperty.length === 1 && + scriptMetadata.arrayProperty[0] === "el1", + "Got the expected value for an array scriptMetadata property" + ); + browser.test.assertTrue( + scriptMetadata.objectProperty && + scriptMetadata.objectProperty.nestedProp === "nestedValue", + "Got the expected value for an object scriptMetadata property" + ); + + browser.test.assertEq( + "param1", + stringParam, + "Got the expected string parameter value" + ); + browser.test.assertEq( + 123, + numberParam, + "Got the expected number parameter value" + ); + browser.test.assertEq( + true, + boolParam, + "Got the expected boolean parameter value" + ); + browser.test.assertEq( + null, + nullParam, + "Got the expected null parameter value" + ); + browser.test.assertEq( + undefined, + undefinedParam, + "Got the expected undefined parameter value" + ); + + browser.test.assertEq( + 3, + arrayParam.length, + "Got the expected length on the array param" + ); + browser.test.assertTrue( + arrayParam.includes(1), + "Got the expected result when calling arrayParam.includes" + ); + + return "returned_value"; + }, + }); + }); + } + + function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + // Redefine the includes method on the Array prototype, to explicitly verify that the method + // redefined in the userScript is not used when accessing arrayParam.includes from the API script. + // eslint-disable-next-line no-extend-native + Array.prototype.includes = () => { + throw new Error("Unexpected prototype leakage"); + }; + const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor + const result = testAPIMethod( + "param1", + 123, + true, + null, + undefined, + arrayParam + ); + + assertTrue( + result === "returned_value", + `userScript got an unexpected result value: ${result}` + ); + + notifyFinish(); + } + + const userScriptMetadata = { + name: "test-user-script-exported-apis", + arrayProperty: ["el1"], + objectProperty: { nestedProp: "nestedValue" }, + nullProperty: null, + }; + + await test_userScript_APIMethod({ + userScript, + apiScript, + userScriptMetadata, + }); +}); + +add_task(async function test_apiScript_async_method() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(param, cb, cb2, objWithCb) { + browser.test.assertEq( + "function", + typeof cb, + "Got a callback function parameter" + ); + browser.test.assertTrue( + cb === cb2, + "Got the same cloned function for the same function parameter" + ); + + browser.runtime.sendMessage(param).then(bgPageRes => { + const cbResult = cb(script.export(bgPageRes)); + browser.test.sendMessage("user-script-callback-return", cbResult); + }); + + return "resolved_value"; + }, + }); + }); + } + + async function userScript() { + // Redefine Promise to verify that it doesn't break the WebExtensions internals + // that are going to use them. + const { Promise } = this; + Promise.resolve = function() { + throw new Error("Promise.resolve poisoning"); + }; + this.Promise = function() { + throw new Error("Promise constructor poisoning"); + }; + + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const cb = cbParam => { + return `callback param: ${JSON.stringify(cbParam)}`; + }; + const cb2 = cb; + const asyncAPIResult = await testAPIMethod("param3", cb, cb2); + + assertTrue( + asyncAPIResult === "resolved_value", + `userScript got an unexpected resolved value: ${asyncAPIResult}` + ); + + notifyFinish(); + } + + async function runtimeMessageListener(param) { + if (param !== "param3") { + browser.test.fail(`Got an unexpected message: ${param}`); + } + + return { bgPageReply: true }; + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + runtimeMessageListener, + async testFn({ extension }) { + const res = await extension.awaitMessage("user-script-callback-return"); + equal( + res, + `callback param: ${JSON.stringify({ bgPageReply: true })}`, + "Got the expected userScript callback return value" + ); + }, + }); +}); + +add_task(async function test_apiScript_method_with_webpage_objects_params() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(windowParam, documentParam) { + browser.test.assertEq( + window, + windowParam, + "Got a reference to the native window as first param" + ); + browser.test.assertEq( + window.document, + documentParam, + "Got a reference to the native document as second param" + ); + + // Return an uncloneable webpage object, which checks that if the returned object is from a principal + // that is subsumed by the userScript sandbox principal, it is returned without being cloned. + return windowParam; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const result = testAPIMethod(window, document); + + // We expect the returned value to be the uncloneable window object. + assertTrue( + result === window, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_got_param_with_methods() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + const scriptGlobal = script.global; + const ScriptFunction = scriptGlobal.Function; + + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(objWithMethods) { + browser.test.assertEq( + "objPropertyValue", + objWithMethods && objWithMethods.objProperty, + "Got the expected property on the object passed as a parameter" + ); + browser.test.assertEq( + undefined, + typeof objWithMethods && objWithMethods.objMethod, + "XrayWrapper should deny access to a callable property" + ); + + browser.test.assertTrue( + objWithMethods && + objWithMethods.wrappedJSObject && + objWithMethods.wrappedJSObject.objMethod instanceof + ScriptFunction.wrappedJSObject, + "The callable property is accessible on the wrappedJSObject" + ); + + browser.test.assertEq( + "objMethodResult: p1", + objWithMethods && + objWithMethods.wrappedJSObject && + objWithMethods.wrappedJSObject.objMethod("p1"), + "Got the expected result when calling the method on the wrappedJSObject" + ); + return true; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let result = testAPIMethod({ + objProperty: "objPropertyValue", + objMethod(param) { + return `objMethodResult: ${param}`; + }, + }); + + assertTrue( + result === true, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_throws_errors() { + function apiScript({ notifyFinish }) { + let proxyTrapsCount = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + const scriptGlobals = { + Error: script.global.Error, + TypeError: script.global.TypeError, + Proxy: script.global.Proxy, + }; + + script.defineGlobals({ + notifyFinish, + testAPIMethod(errorTestName, returnRejectedPromise) { + let err; + + switch (errorTestName) { + case "apiScriptError": + err = new Error(`${errorTestName} message`); + break; + case "apiScriptThrowsPlainString": + err = `${errorTestName} message`; + break; + case "apiScriptThrowsNull": + err = null; + break; + case "userScriptError": + err = new scriptGlobals.Error(`${errorTestName} message`); + break; + case "userScriptTypeError": + err = new scriptGlobals.TypeError(`${errorTestName} message`); + break; + case "userScriptProxyObject": + let proxyTarget = script.export({ + name: "ProxyObject", + message: "ProxyObject message", + }); + let proxyHandlers = script.export({ + get(target, prop) { + proxyTrapsCount++; + switch (prop) { + case "name": + return "ProxyObjectGetName"; + case "message": + return "ProxyObjectGetMessage"; + } + return undefined; + }, + getPrototypeOf() { + proxyTrapsCount++; + return scriptGlobals.TypeError; + }, + }); + err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers); + break; + default: + browser.test.fail(`Unknown ${errorTestName} error testname`); + return undefined; + } + + if (returnRejectedPromise) { + return Promise.reject(err); + } + + throw err; + }, + assertNoProxyTrapTriggered() { + browser.test.assertEq( + 0, + proxyTrapsCount, + "Proxy traps should not be triggered" + ); + }, + resetProxyTrapCounter() { + proxyTrapsCount = 0; + }, + sendResults(results) { + browser.test.sendMessage("test-results", results); + }, + }); + }); + } + + async function userScript() { + const { + assertNoProxyTrapTriggered, + notifyFinish, + resetProxyTrapCounter, + sendResults, + testAPIMethod, + } = this; + + let apiThrowResults = {}; + let apiThrowTestCases = [ + "apiScriptError", + "apiScriptThrowsPlainString", + "apiScriptThrowsNull", + "userScriptError", + "userScriptTypeError", + "userScriptProxyObject", + ]; + for (let errorTestName of apiThrowTestCases) { + try { + testAPIMethod(errorTestName); + } catch (err) { + // We expect that no proxy traps have been triggered by the WebExtensions internals. + if (errorTestName === "userScriptProxyObject") { + assertNoProxyTrapTriggered(); + } + + if (err instanceof Error) { + apiThrowResults[errorTestName] = { + name: err.name, + message: err.message, + }; + } else { + apiThrowResults[errorTestName] = { + name: err && err.name, + message: err && err.message, + typeOf: typeof err, + value: err, + }; + } + } + } + + sendResults(apiThrowResults); + + resetProxyTrapCounter(); + + let apiRejectsResults = {}; + for (let errorTestName of apiThrowTestCases) { + try { + await testAPIMethod(errorTestName, true); + } catch (err) { + // We expect that no proxy traps have been triggered by the WebExtensions internals. + if (errorTestName === "userScriptProxyObject") { + assertNoProxyTrapTriggered(); + } + + if (err instanceof Error) { + apiRejectsResults[errorTestName] = { + name: err.name, + message: err.message, + }; + } else { + apiRejectsResults[errorTestName] = { + name: err && err.name, + message: err && err.message, + typeOf: typeof err, + value: err, + }; + } + } + } + + sendResults(apiRejectsResults); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + async testFn({ extension }) { + const expectedResults = { + // Any error not explicitly raised as a userScript objects or error instance is + // expected to be turned into a generic error message. + apiScriptError: { + name: "Error", + message: "An unexpected apiScript error occurred", + }, + + // When the api script throws a primitive value, we expect to receive it unmodified on + // the userScript side. + apiScriptThrowsPlainString: { + typeOf: "string", + value: "apiScriptThrowsPlainString message", + name: undefined, + message: undefined, + }, + apiScriptThrowsNull: { + typeOf: "object", + value: null, + name: undefined, + message: undefined, + }, + + // Error messages that the apiScript has explicitly created as userScript's Error + // global instances are expected to be passing through unmodified. + userScriptError: { name: "Error", message: "userScriptError message" }, + userScriptTypeError: { + name: "TypeError", + message: "userScriptTypeError message", + }, + + // Error raised from the apiScript as userScript proxy objects are expected to + // be passing through unmodified. + userScriptProxyObject: { + typeOf: "object", + name: "ProxyObjectGetName", + message: "ProxyObjectGetMessage", + }, + }; + + info( + "Checking results from errors raised from an apiScript exported function" + ); + + const apiThrowResults = await extension.awaitMessage("test-results"); + + for (let [key, expected] of Object.entries(expectedResults)) { + Assert.deepEqual( + apiThrowResults[key], + expected, + `Got the expected error object for test case "${key}"` + ); + } + + Assert.deepEqual( + Object.keys(expectedResults).sort(), + Object.keys(apiThrowResults).sort(), + "the expected and actual test case names matches" + ); + + info( + "Checking expected results from errors raised from an apiScript exported function" + ); + + // Verify expected results from rejected promises returned from an apiScript exported function. + const apiThrowRejections = await extension.awaitMessage("test-results"); + + for (let [key, expected] of Object.entries(expectedResults)) { + Assert.deepEqual( + apiThrowRejections[key], + expected, + `Got the expected rejected object for test case "${key}"` + ); + } + + Assert.deepEqual( + Object.keys(expectedResults).sort(), + Object.keys(apiThrowRejections).sort(), + "the expected and actual test case names matches" + ); + }, + }); +}); + +add_task( + async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(...args) { + // Proxies are opaque when wrapped in Xrays, and the proto of an opaque object + // is supposed to be Object.prototype. + browser.test.assertEq( + script.global.Object.prototype, + Object.getPrototypeOf(args[0]), + "Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap" + ); + + browser.test.assertTrue( + Array.isArray(args[0]), + "Got an array object for the XrayWrapped proxy object param" + ); + browser.test.assertEq( + undefined, + args[0].length, + "XrayWrappers deny access to the length property" + ); + browser.test.assertEq( + undefined, + args[0][0], + "Got the expected item in the array object" + ); + return true; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let proxy = new Proxy(["expectedArrayValue"], { + getPrototypeOf() { + throw new Error("Proxy's getPrototypeOf trap"); + }, + get(target, prop, receiver) { + throw new Error("Proxy's get trap"); + }, + }); + + let result = testAPIMethod(proxy); + + assertTrue( + result, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_return_proxy_object() { + function apiScript(sharedTestAPIMethods) { + let proxyTrapsCount = 0; + let scriptTrapsCount = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodError() { + return new Proxy(["expectedArrayValue"], { + getPrototypeOf(target) { + proxyTrapsCount++; + return Object.getPrototypeOf(target); + }, + }); + }, + testAPIMethodOk() { + return new script.global.Proxy( + script.export(["expectedArrayValue"]), + script.export({ + getPrototypeOf(target) { + scriptTrapsCount++; + return script.global.Object.getPrototypeOf(target); + }, + }) + ); + }, + assertNoProxyTrapTriggered() { + browser.test.assertEq( + 0, + proxyTrapsCount, + "Proxy traps should not be triggered" + ); + }, + assertScriptProxyTrapsCount(expected) { + browser.test.assertEq( + expected, + scriptTrapsCount, + "Script Proxy traps should have been triggered" + ); + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + assertNoProxyTrapTriggered, + assertScriptProxyTrapsCount, + notifyFinish, + testAPIMethodError, + testAPIMethodOk, + } = this; + + let error; + try { + let result = testAPIMethodError(); + notifyFinish( + `Unexpected returned value while expecting error: ${result}` + ); + return; + } catch (err) { + error = err; + } + + assertTrue( + error && + error.message.includes("Return value not accessible to the userScript"), + `Got an unexpected error message: ${error}` + ); + + error = undefined; + try { + let result = testAPIMethodOk(); + assertScriptProxyTrapsCount(0); + if (!(result instanceof Array)) { + notifyFinish(`Got an unexpected result: ${result}`); + return; + } + assertScriptProxyTrapsCount(1); + } catch (err) { + error = err; + } + + assertTrue(!error, `Got an unexpected error: ${error}`); + + assertNoProxyTrapTriggered(); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_returns_functions() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIReturnsFunction() { + // Return a function with provides the same kind of behavior + // of the API methods exported as globals. + return script.export(() => window); + }, + testAPIReturnsObjWithMethod() { + return script.export({ + getWindow() { + return window; + }, + }); + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIReturnsFunction, + testAPIReturnsObjWithMethod, + } = this; + + let resultFn = testAPIReturnsFunction(); + assertTrue( + typeof resultFn === "function", + `userScript got an unexpected returned value: ${typeof resultFn}` + ); + + let fnRes = resultFn(); + assertTrue( + fnRes === window, + `Got an unexpected value from the returned function: ${fnRes}` + ); + + let resultObj = testAPIReturnsObjWithMethod(); + let actualTypeof = resultObj && typeof resultObj.getWindow; + assertTrue( + actualTypeof === "function", + `Returned object does not have the expected getWindow method: ${actualTypeof}` + ); + + let methodRes = resultObj.getWindow(); + assertTrue( + methodRes === window, + `Got an unexpected value from the returned method: ${methodRes}` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task( + async function test_apiScript_method_clone_non_subsumed_returned_values() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodReturnOk() { + return script.export({ + objKey1: { + nestedProp: "nestedvalue", + }, + window, + }); + }, + testAPIMethodExplicitlyClonedError() { + let result = script.export({ apiScopeObject: undefined }); + + browser.test.assertThrows( + () => { + result.apiScopeObject = { disallowedProp: "disallowedValue" }; + }, + /Not allowed to define cross-origin object as property on .* XrayWrapper/, + "Assigning a property to a xRayWrapper is expected to throw" + ); + + // Let the exception to be raised, so that we check that the actual underlying + // error message is not leaking in the userScript (replaced by the generic + // "An unexpected apiScript error occurred" error message). + result.apiScopeObject = { disallowedProp: "disallowedValue" }; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethodReturnOk, + testAPIMethodExplicitlyClonedError, + } = this; + + let result = testAPIMethodReturnOk(); + + assertTrue( + result && + "objKey1" in result && + result.objKey1.nestedProp === "nestedvalue", + `userScript got an unexpected returned value: ${result}` + ); + + assertTrue( + result.window === window, + `userScript should have access to the window property: ${result.window}` + ); + + let error; + try { + result = testAPIMethodExplicitlyClonedError(); + notifyFinish( + `Unexpected returned value while expecting error: ${result}` + ); + return; + } catch (err) { + error = err; + } + + // We expect the generic "unexpected apiScript error occurred" to be raised to the + // userScript code. + assertTrue( + error && + error.message.includes("An unexpected apiScript error occurred"), + `Got an unexpected error message: ${error}` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_export_primitive_types() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(typeToExport) { + switch (typeToExport) { + case "boolean": + return script.export(true); + case "number": + return script.export(123); + case "string": + return script.export("a string"); + case "symbol": + return script.export(Symbol("a symbol")); + } + return undefined; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let v = testAPIMethod("boolean"); + assertTrue(v === true, `Should export a boolean`); + + v = testAPIMethod("number"); + assertTrue(v === 123, `Should export a number`); + + v = testAPIMethod("string"); + assertTrue(v === "a string", `Should export a string`); + + v = testAPIMethod("symbol"); + assertTrue(typeof v === "symbol", `Should export a symbol`); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task( + async function test_apiScript_method_avoid_unnecessary_params_cloning() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodReturnsParam(param) { + return param; + }, + testAPIMethodReturnsUnwrappedParam(param) { + return param.wrappedJSObject; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethodReturnsParam, + testAPIMethodReturnsUnwrappedParam, + } = this; + + let obj = {}; + + let result = testAPIMethodReturnsParam(obj); + + assertTrue( + result === obj, + `Expect returned value to be strictly equal to the API method parameter` + ); + + result = testAPIMethodReturnsUnwrappedParam(obj); + + assertTrue( + result === obj, + `Expect returned value to be strictly equal to the unwrapped API method parameter` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_export_sparse_arrays() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod() { + const sparseArray = []; + sparseArray[3] = "third-element"; + sparseArray[5] = "fifth-element"; + return script.export(sparseArray); + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const result = testAPIMethod(window, document); + + // We expect the returned value to be the uncloneable window object. + assertTrue( + result && result.length === 6, + `the returned value should be an array of the expected length: ${result}` + ); + assertTrue( + result[3] === "third-element", + `the third array element should have the expected value: ${result[3]}` + ); + assertTrue( + result[5] === "fifth-element", + `the fifth array element should have the expected value: ${result[5]}` + ); + assertTrue( + result[0] === undefined, + `the first array element should have the expected value: ${result[0]}` + ); + assertTrue(!("0" in result), "Holey array should still be holey"); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js new file mode 100644 index 0000000000..08d61d1e85 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js @@ -0,0 +1,175 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_USER_SCRIPT_INJECTION_MS"; +const HISTOGRAM_KEYED = "WEBEXT_USER_SCRIPT_INJECTION_MS_BY_ADDONID"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_userScripts_telemetry() { + function apiScript() { + browser.userScripts.onBeforeScript.addListener(userScript => { + const scriptMetadata = userScript.metadata; + + userScript.defineGlobals({ + US_test_sendMessage(msg, data) { + browser.test.sendMessage(msg, { data, scriptMetadata }); + }, + }); + }); + } + + async function background() { + const code = ` + US_test_sendMessage("userScript-run", {location: window.location.href}); + `; + await browser.userScripts.register({ + js: [{ code }], + matches: ["http://*/*/file_sample.html"], + runAt: "document_end", + scriptMetadata: { + name: "test-user-script-telemetry", + }, + }); + + browser.test.sendMessage("userScript-registered"); + } + + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + let testExtensionDef = { + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { + api_script: "api-script.js", + }, + }, + background, + files: { + "api-script.js": apiScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(testExtensionDef); + let extension2 = ExtensionTestUtils.loadExtension(testExtensionDef); + let contentPage = await ExtensionTestUtils.loadContentPage("about:blank"); + + clearHistograms(); + + let process = IS_OOP ? "content" : "parent"; + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + + await extension.startup(); + await extension.awaitMessage("userScript-registered"); + + let extensionId = extension.extension.id; + + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram after startup: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + + let url = `${BASE_URL}/file_sample.html`; + contentPage.loadURL(url); + const res = await extension.awaitMessage("userScript-run"); + Assert.deepEqual( + res, + { + data: { location: url }, + scriptMetadata: { name: "test-user-script-telemetry" }, + }, + "The userScript has been executed on the content page as expected" + ); + + await promiseTelemetryRecorded(HISTOGRAM, process, 1); + await promiseKeyedTelemetryRecorded(HISTOGRAM_KEYED, process, extensionId, 1); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + + await contentPage.close(); + await extension.unload(); + + await extension2.startup(); + await extension2.awaitMessage("userScript-registered"); + let extensionId2 = extension2.extension.id; + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `No data recorded for histogram after startup: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram after extension2 startup: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + ok( + !(extensionId2 in getKeyedSnapshots(process)[HISTOGRAM_KEYED]), + `No data recorded for histogram after startup: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + + contentPage = await ExtensionTestUtils.loadContentPage(url); + const res2 = await extension2.awaitMessage("userScript-run"); + Assert.deepEqual( + res2, + { + data: { location: url }, + scriptMetadata: { name: "test-user-script-telemetry" }, + }, + "The userScript has been executed on the content page as expected" + ); + + await promiseTelemetryRecorded(HISTOGRAM, process, 2); + await promiseKeyedTelemetryRecorded( + HISTOGRAM_KEYED, + process, + extensionId2, + 1 + ); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 2, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId2].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + + await contentPage.close(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js new file mode 100644 index 0000000000..c616d162a5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js @@ -0,0 +1,425 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; + +// Save seen realms for cache checking. +let realms = new Set([]); + +server.registerPathHandler("/authenticate.sjs", (request, response) => { + let url = new URL(`${BASE_URL}${request.path}?${request.queryString}`); + let realm = url.searchParams.get("realm") || "mochitest"; + let proxy_realm = url.searchParams.get("proxy_realm"); + + function checkAuthorization(authorization) { + let expected_user = url.searchParams.get("user"); + if (!expected_user) { + return true; + } + let expected_pass = url.searchParams.get("pass"); + let actual_user, actual_pass; + let authHeader = request.getHeader("Authorization"); + let match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + let userpass = atob(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + actual_user = match[1]; + actual_pass = match[2]; + return expected_user === actual_user && expected_pass === actual_pass; + } + + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + if (proxy_realm && !request.hasHeader("Proxy-Authorization")) { + // We're not testing anything that requires checking the proxy auth user/password. + response.setStatusLine("1.0", 407, "Proxy authentication required"); + response.setHeader( + "Proxy-Authenticate", + `basic realm="${proxy_realm}"`, + true + ); + response.write("proxy auth required"); + } else if ( + !( + realms.has(realm) && + request.hasHeader("Authorization") && + checkAuthorization() + ) + ) { + realms.add(realm); + response.setStatusLine(request.httpVersion, 401, "Authentication required"); + response.setHeader("WWW-Authenticate", `basic realm="${realm}"`, true); + response.write("auth required"); + } else { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok, got authorization"); + } +}); + +function getExtension(bgConfig) { + function background(config) { + let path = config.path; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log( + `onBeforeRequest called with ${details.requestId} ${details.url}` + ); + browser.test.sendMessage("onBeforeRequest"); + return ( + config.onBeforeRequest.hasOwnProperty("result") && + config.onBeforeRequest.result + ); + }, + { urls: [path] }, + config.onBeforeRequest.hasOwnProperty("extra") + ? config.onBeforeRequest.extra + : [] + ); + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log( + `onAuthRequired called with ${details.requestId} ${details.url}` + ); + browser.test.assertEq( + config.realm, + details.realm, + "providing www authorization" + ); + browser.test.sendMessage("onAuthRequired"); + return ( + config.onAuthRequired.hasOwnProperty("result") && + config.onAuthRequired.result + ); + }, + { urls: [path] }, + config.onAuthRequired.hasOwnProperty("extra") + ? config.onAuthRequired.extra + : [] + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log( + `onCompleted called with ${details.requestId} ${details.url}` + ); + browser.test.sendMessage("onCompleted"); + }, + { urls: [path] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log( + `onErrorOccurred called with ${JSON.stringify(details)}` + ); + browser.test.sendMessage("onErrorOccurred"); + }, + { urls: [path] } + ); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", bgConfig.path], + }, + background: `(${background})(${JSON.stringify(bgConfig)})`, + }); +} + +add_task(async function test_webRequest_auth() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let extension = getExtension(config); + await extension.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onCompleted"), + ]); + }), + ]); + await contentPage.close(); + + // Second time around to test cached credentials + contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onCompleted"), + ]); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_webRequest_auth_cancelled() { + // Test that any auth listener can cancel. + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let ex1 = getExtension(config); + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired"), + ex1.awaitMessage("onErrorOccurred"), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired"), + ex2.awaitMessage("onErrorOccurred"), + ]); + + await contentPage.close(); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_auth_nonblocking() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let ex1 = getExtension(config); + // non-blocking ext tries to cancel but cannot. + delete config.onBeforeRequest.extra; + delete config.onAuthRequired.extra; + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onCompleted"), + ]); + }), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onCompleted"), + ]); + }), + ]); + + await contentPage.close(); + Services.obs.notifyObservers(null, "net:clear-active-logins"); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_auth_blocking_noreturn() { + // The first listener is blocking but doesn't return anything. The second + // listener cancels the request. + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + }, + }; + + let ex1 = getExtension(config); + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired"), + ex1.awaitMessage("onErrorOccurred"), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired"), + ex2.awaitMessage("onErrorOccurred"), + ]); + + await contentPage.close(); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_duelingAuth() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + }, + }; + let exNone = getExtension(config); + await exNone.startup(); + + let authCredentials = { + username: `testuser_da1${Math.random()}`, + password: `testpass_da1${Math.random()}`, + }; + config.onAuthRequired.result = { authCredentials }; + let ex1 = getExtension(config); + await ex1.startup(); + + config.onAuthRequired.result = {}; + let exEmpty = getExtension(config); + await exEmpty.startup(); + + config.onAuthRequired.result = { + authCredentials: { + username: `testuser_da2${Math.random()}`, + password: `testpass_da2${Math.random()}`, + }, + }; + let ex2 = getExtension(config); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}&user=${authCredentials.username}&pass=${authCredentials.password}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + exNone.awaitMessage("onBeforeRequest"), + exNone.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + exNone.awaitMessage("onBeforeRequest"), + exNone.awaitMessage("onCompleted"), + ]); + }), + exEmpty.awaitMessage("onBeforeRequest"), + exEmpty.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + exEmpty.awaitMessage("onBeforeRequest"), + exEmpty.awaitMessage("onCompleted"), + ]); + }), + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onCompleted"), + ]); + }), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onCompleted"), + ]); + }), + ]); + + await Promise.all([ + await contentPage.close(), + exNone.unload(), + exEmpty.unload(), + ex1.unload(), + ex2.unload(), + ]); +}); + +add_task(async function test_webRequest_auth_proxy() { + function background(permissionPath) { + let proxyOk = false; + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log( + `handlingExt onAuthRequired called with ${details.requestId} ${details.url}` + ); + if (details.isProxy) { + browser.test.succeed("providing proxy authorization"); + proxyOk = true; + return { authCredentials: { username: "puser", password: "ppass" } }; + } + browser.test.assertTrue( + proxyOk, + "providing www authorization after proxy auth" + ); + browser.test.sendMessage("done"); + return { authCredentials: { username: "auser", password: "apass" } }; + }, + { urls: [permissionPath] }, + ["blocking"] + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/*`], + }, + background: `(${background})("${BASE_URL}/*")`, + }); + + await handlingExt.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=webRequest_auth${Math.random()}&proxy_realm=proxy_auth${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js new file mode 100644 index 0000000000..c18c75a580 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js @@ -0,0 +1,311 @@ +"use strict"; + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/data/file_sample.html"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); +server.registerPathHandler("/status", (request, response) => { + let IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + switch (IfNoneMatch) { + case "1234567890": + response.setStatusLine("1.1", 304, "Not Modified"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Etag", "1234567890", false); + break; + case "": + response.setStatusLine("1.1", 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Etag", "1234567890", false); + response.write("ok"); + break; + default: + throw new Error(`Unexpected If-None-Match: ${IfNoneMatch}`); + } +}); + +// This test initialises a cache entry with a CSP header, then +// loads the cached entry and replaces the CSP header with +// a new one. We test in onResponseStarted that the header +// is what we expect. +add_task(async function test_replaceResponseHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + function replaceHeader(headers, newHeader) { + headers = headers.filter(header => header.name !== newHeader.name); + headers.push(newHeader); + return headers; + } + let testHeaders = [ + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src 'none'", + }, + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src https:", + }, + ]; + browser.webRequest.onHeadersReceived.addListener( + details => { + if (!details.fromCache) { + // Add a CSP header on the initial request + details.responseHeaders.push(testHeaders[0]); + return { + responseHeaders: details.responseHeaders, + }; + } + // Test that the header added during the initial request is + // now in the cached response. + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == "Content-Security-Policy"; + }); + browser.test.assertEq( + header[0].value, + testHeaders[0].value, + "pre-cached header exists" + ); + // Replace the cached value so we can test overriding the header that was cached. + return { + responseHeaders: replaceHeader( + details.responseHeaders, + testHeaders[1] + ), + }; + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onResponseStarted.addListener( + details => { + let needle = details.fromCache ? testHeaders[1] : testHeaders[0]; + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == needle.name && header.value == needle.value; + }); + browser.test.assertEq( + header.length, + 1, + "header exists with correct value" + ); + if (details.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await extension.awaitMessage("from-cache"); + + await extension.unload(); +}); + +// This test initialises a cache entry with a CSP header, then +// loads the cached entry and adds a second CSP header. We also +// test that the browser has the CSP entries we expect. +add_task(async function test_addCSPHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + let testHeaders = [ + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src 'none'", + }, + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src https:", + }, + ]; + browser.webRequest.onHeadersReceived.addListener( + details => { + if (!details.fromCache) { + details.responseHeaders.push(testHeaders[0]); + return { + responseHeaders: details.responseHeaders, + }; + } + browser.test.log("cached request received"); + details.responseHeaders.push(testHeaders[1]); + return { + responseHeaders: details.responseHeaders, + }; + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onCompleted.addListener( + details => { + let { name, value } = testHeaders[0]; + if (details.fromCache) { + value = `${value}, ${testHeaders[1].value}`; + } + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == name && header.value == value; + }); + browser.test.assertEq( + header.length, + 1, + "header exists with correct value" + ); + if (details.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + equal(contentPage.browser.csp.policyCount, 1, "expected 1 policy"); + equal( + contentPage.browser.csp.getPolicy(0), + "object-src 'none'; script-src 'none'", + "expected policy" + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage(url); + equal(contentPage.browser.csp.policyCount, 2, "expected 2 policies"); + equal( + contentPage.browser.csp.getPolicy(0), + "object-src 'none'; script-src 'none'", + "expected first policy" + ); + equal( + contentPage.browser.csp.getPolicy(1), + "object-src 'none'; script-src https:", + "expected second policy" + ); + + await extension.awaitMessage("from-cache"); + await contentPage.close(); + + await extension.unload(); +}); + +// This test verifies that a content type changed during +// onHeadersReceived is cached. We initialize the cache, +// then load against a url that will specifically return +// a 304 status code. +add_task(async function test_addContentTypeHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`); + }, + { + urls: ["http://example.com/status*"], + }, + ["blocking", "requestHeaders"] + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.log(`onHeadersReceived ${JSON.stringify(details)}\n`); + if (!details.fromCache) { + browser.test.sendMessage("statusCode", details.statusCode); + const mime = details.responseHeaders.find(header => { + return header.value && header.name === "content-type"; + }); + if (mime) { + mime.value = "text/plain"; + } else { + details.responseHeaders.push({ + name: "content-type", + value: "text/plain", + }); + } + return { + responseHeaders: details.responseHeaders, + }; + } + }, + { + urls: ["http://example.com/status*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + const mime = details.responseHeaders.find(header => { + return header.value && header.name === "content-type"; + }); + browser.test.sendMessage("contentType", mime.value); + }, + { + urls: ["http://example.com/status*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/status` + ); + equal(await extension.awaitMessage("statusCode"), "200", "status OK"); + equal( + await extension.awaitMessage("contentType"), + "text/plain", + "plain text header" + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/status`); + equal(await extension.awaitMessage("statusCode"), "304", "not modified"); + equal( + await extension.awaitMessage("contentType"), + "text/plain", + "plain text header" + ); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js new file mode 100644 index 0000000000..de9ed535b3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js @@ -0,0 +1,69 @@ +"use strict"; + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_cancel_with_reason() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "cancel@test" } }, + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { cancel: true }; + }, + { urls: ["*://*/*"] }, + ["blocking"] + ); + }, + }); + await ext.startup(); + + let data = await new Promise(resolve => { + let ssm = Services.scriptSecurityManager; + + let channel = NetUtil.newChannel({ + uri: `${gServerUrl}/dummy`, + loadingPrincipal: ssm.createContentPrincipalFromOrigin( + "http://localhost" + ), + contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + }); + + channel.asyncOpen({ + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onStartRequest(request) {}, + + onStopRequest(request, statusCode) { + let properties = request.QueryInterface(Ci.nsIPropertyBag); + let id = properties.getProperty("cancelledByExtension"); + let reason = request.loadInfo.requestBlockingReason; + resolve({ reason, id }); + }, + + onDataAvailable() {}, + }); + }); + + Assert.equal( + Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST, + data.reason, + "extension cancelled request" + ); + Assert.equal( + ext.id, + data.id, + "extension id attached to channel property bag" + ); + await ext.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js new file mode 100644 index 0000000000..75acb39000 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js @@ -0,0 +1,43 @@ +"use strict"; + +// Test for Bug 1579911: Check that download requests created by the +// downloads.download API can be observed by extensions. +add_task(async function testDownload() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "downloads", + "https://example.com/*", + ], + }, + background: async function() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("request_intercepted"); + return { cancel: true }; + }, + { + urls: ["https://example.com/downloadtest"], + }, + ["blocking"] + ); + + browser.downloads.onChanged.addListener(delta => { + browser.test.assertEq(delta.state.current, "interrupted"); + browser.test.sendMessage("done"); + }); + + await browser.downloads.download({ + url: "https://example.com/downloadtest", + filename: "example.txt", + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("request_intercepted"); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js new file mode 100644 index 0000000000..27f4ff01e8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js @@ -0,0 +1,523 @@ +"use strict"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +const HOSTS = new Set(["example.com", "example.org", "example.net"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/redirect", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", params.get("redirect_uri")); + response.setHeader("Access-Control-Allow-Origin", "*"); +}); + +server.registerPathHandler("/redirect301", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 301, "Moved Permanently"); + response.setHeader("Location", params.get("redirect_uri")); + response.setHeader("Access-Control-Allow-Origin", "*"); +}); + +server.registerPathHandler("/script302.js", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", "http://example.com/script.js"); +}); + +server.registerPathHandler("/script.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/javascript"); + response.write(String.raw`console.log("HELLO!");`); +}); + +server.registerPathHandler("/302.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + response.write(String.raw` + <script type="application/javascript" src="http://example.com/script302.js"></script> + `); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.write("ok"); +}); + +server.registerPathHandler("/dummy.xhtml", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/xhtml+xml"); + response.write(String.raw`<?xml version="1.0"?> + <html xml:lang="en" xmlns="http://www.w3.org/1999/xhtml"> + <head/> + <body/> + </html> + `); +}); + +server.registerPathHandler("/lorem.html.gz", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + "Content-Type: text/html; charset=utf-8", + false + ); + response.setHeader("Content-Encoding", "gzip", false); + + let data = await OS.File.read(do_get_file("data/lorem.html.gz").path); + response.write(String.fromCharCode(...new Uint8Array(data))); + + response.finish(); +}); + +// Test re-encoding the data stream for bug 1590898. +add_task(async function test_stream_encoding_data() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + request => { + let filter = browser.webRequest.filterResponseData(request.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + }; + }, + { + urls: ["http://example.com/lorem.html.gz"], + types: ["main_frame"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/lorem.html.gz" + ); + + let content = await contentPage.spawn(null, () => { + return this.content.document.body.textContent; + }); + + ok( + content.includes("Lorem ipsum dolor sit amet"), + `expected content received` + ); + + await contentPage.close(); + await extension.unload(); +}); + +// Tests that the stream filter request is added to the document's load +// group, and blocks an XML document's load event until after the filter +// stops sending data. +add_task(async function test_xml_document_loadgroup_blocking() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + request => { + let filter = browser.webRequest.filterResponseData(request.requestId); + + let data = []; + filter.ondata = event => { + data.push(event.data); + }; + filter.onstop = async () => { + browser.test.sendMessage("phase", "original-onstop"); + + // Make a few trips through the event loop. + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + for (let buffer of data) { + filter.write(buffer); + } + browser.test.sendMessage("phase", "filter-onstop"); + filter.close(); + }; + }, + { + urls: ["http://example.com/dummy.xhtml"], + }, + ["blocking"] + ); + }, + + files: { + "content_script.js"() { + browser.test.sendMessage("phase", "content-script-start"); + window.addEventListener( + "DOMContentLoaded", + () => { + browser.test.sendMessage("phase", "content-script-domload"); + }, + { once: true } + ); + window.addEventListener( + "load", + () => { + browser.test.sendMessage("phase", "content-script-load"); + }, + { once: true } + ); + }, + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + + content_scripts: [ + { + matches: ["http://example.com/dummy.xhtml"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + }); + + await extension.startup(); + + const EXPECTED = [ + "original-onstop", + "filter-onstop", + "content-script-start", + "content-script-domload", + "content-script-load", + ]; + + let done = new Promise(resolve => { + let phases = []; + extension.onMessage("phase", phase => { + phases.push(phase); + if (phases.length === EXPECTED.length) { + resolve(phases); + } + }); + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy.xhtml" + ); + + deepEqual(await done, EXPECTED, "Things happened, and in the right order"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_filter_content_fetch() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let pending = []; + + browser.webRequest.onBeforeRequest.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + let url = new URL(data.url); + + if (url.searchParams.get("redirect_uri")) { + pending.push( + new Promise(resolve => { + filter.onerror = resolve; + }).then(() => { + browser.test.assertEq( + "Channel redirected", + filter.error, + "Got correct error for redirected filter" + ); + }) + ); + } + + filter.onstart = () => { + filter.write(new TextEncoder().encode(data.url)); + }; + filter.ondata = event => { + let str = new TextDecoder().decode(event.data); + browser.test.assertEq( + "ok", + str, + `Got unfiltered data for ${data.url}` + ); + }; + filter.onstop = () => { + filter.close(); + }; + }, + { + urls: ["<all_urls>"], + }, + ["blocking"] + ); + + browser.test.onMessage.addListener(async msg => { + if (msg === "done") { + await Promise.all(pending); + browser.test.notifyPass("stream-filter"); + } + }); + }, + + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/", + "http://example.org/", + ], + }, + }); + + await extension.startup(); + + let results = [ + ["http://example.com/dummy", "http://example.com/dummy"], + ["http://example.org/dummy", "http://example.org/dummy"], + ["http://example.net/dummy", "ok"], + [ + "http://example.com/redirect?redirect_uri=http://example.com/dummy", + "http://example.com/dummy", + ], + [ + "http://example.com/redirect?redirect_uri=http://example.org/dummy", + "http://example.org/dummy", + ], + ["http://example.com/redirect?redirect_uri=http://example.net/dummy", "ok"], + [ + "http://example.net/redirect?redirect_uri=http://example.com/dummy", + "http://example.com/dummy", + ], + ].map(async ([url, expectedResponse]) => { + let text = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + equal(text, expectedResponse, `Expected response for ${url}`); + }); + + await Promise.all(results); + + extension.sendMessage("done"); + await extension.awaitFinish("stream-filter"); + await extension.unload(); +}); + +add_task(async function test_filter_301() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onHeadersReceived.addListener( + data => { + if (data.statusCode !== 200) { + return; + } + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = () => { + filter.close(); + browser.test.notifyPass("stream-filter"); + }; + filter.onerror = () => { + browser.test.fail(`unexpected ${filter.error}`); + }; + }, + { + urls: ["<all_urls>"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/", + "http://example.org/", + ], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/redirect301?redirect_uri=http://example.org/dummy" + ); + + await extension.awaitFinish("stream-filter"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_filter_302() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + browser.test.sendMessage("filter-created"); + + filter.ondata = event => { + const script = "forceError();"; + filter.write( + new Uint8Array(new TextEncoder("utf-8").encode(script)) + ); + filter.close(); + browser.test.sendMessage("filter-ondata"); + }; + + filter.onerror = () => { + browser.test.assertEq(filter.error, "Channel redirected"); + browser.test.sendMessage("filter-redirect"); + }; + }, + { + urls: ["http://example.com/*.js"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let { messages } = await promiseConsoleOutput(async () => { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/302.html" + ); + + await extension.awaitMessage("filter-created"); + await extension.awaitMessage("filter-redirect"); + await extension.awaitMessage("filter-created"); + await extension.awaitMessage("filter-ondata"); + await contentPage.close(); + }); + AddonTestUtils.checkMessages(messages, { + expected: [{ message: /forceError is not defined/ }], + }); + + await extension.unload(); +}); + +add_task(async function test_alternate_cached_data() { + Services.prefs.setBoolPref("dom.script_loader.bytecode_cache.enabled", true); + Services.prefs.setIntPref("dom.script_loader.bytecode_cache.strategy", -1); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + browser.test.assertTrue( + str.startsWith(`"use strict";`), + "ondata received decoded data" + ); + browser.test.sendMessage("onBeforeRequest"); + }; + + filter.onerror = () => { + // onBeforeRequest will always beat the cache race, so we should always + // get valid data in ondata. + browser.test.fail("error-received", filter.error); + }; + }, + { + urls: ["http://example.com/data/file_script_good.js"], + }, + ["blocking"] + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + // Because cache is always a race, intermittently we will succesfully + // beat the cache, in which case we pass in ondata. If cache wins, + // we pass in onerror. + // Running the test with --verify hits this cache race issue, as well + // it seems that the cache primarily looses on linux1804. + let gotone = false; + filter.ondata = event => { + browser.test.assertFalse(gotone, "cache lost the race"); + gotone = true; + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + browser.test.assertTrue( + str.startsWith(`"use strict";`), + "ondata received decoded data" + ); + browser.test.sendMessage("onHeadersReceived"); + }; + + filter.onerror = () => { + browser.test.assertFalse(gotone, "cache won the race"); + gotone = true; + browser.test.assertEq( + filter.error, + "Channel is delivering cached alt-data" + ); + browser.test.sendMessage("onHeadersReceived"); + }; + }, + { + urls: ["http://example.com/data/file_script_bad.js"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/*"], + }, + }); + + // Prime the cache so we have the script byte-cached. + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_script.html" + ); + await contentPage.close(); + + await extension.startup(); + + let page_cached = await await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_script.html" + ); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onHeadersReceived"), + ]); + await page_cached.close(); + await extension.unload(); + + Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.enabled"); + Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.strategy"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js new file mode 100644 index 0000000000..643a375ff0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js @@ -0,0 +1,85 @@ +"use strict"; + +AddonTestUtils.init(this); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (request, response) => { + response.setHeader("Content-Tpe", "text/plain", false); + response.write("OK"); +}); + +add_task(async function test_all_webRequest_ResourceTypes() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"], + }, + background() { + browser.test.onMessage.addListener(async msg => { + browser.webRequest[msg.event].addListener( + () => {}, + { urls: ["*://example.com/*"], ...msg.filter }, + ["blocking"] + ); + // Call an API method implemented in the parent process to + // be sure that the webRequest listener has been registered + // in the parent process as well. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage(`webRequest-listener-registered`); + }); + }, + }); + + await extension.startup(); + + const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + const webRequestSchema = Schemas.privilegedSchemaJSON + .get("chrome://extensions/content/schemas/web_request.json") + .deserialize({}); + const ResourceType = webRequestSchema[1].types.filter( + type => type.id == "ResourceType" + )[0]; + ok( + ResourceType && ResourceType.enum, + "Found ResourceType in the web_request.json schema" + ); + info( + "Register webRequest.onBeforeRequest event listener for all supported ResourceType" + ); + + let { messages } = await promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + extension.sendMessage({ + event: "onBeforeRequest", + filter: { + // Verify that the resourceType not supported is going to be ignored + // and all the ones supported does not trigger a ChannelWrapper.matches + // exception once the listener is being triggered. + types: [].concat(ResourceType.enum, "not-supported-resource-type"), + }, + }); + await extension.awaitMessage("webRequest-listener-registered"); + ExtensionTestUtils.failOnSchemaWarnings(); + + await ExtensionTestUtils.fetch( + "http://example.com/dummy", + "http://example.com" + ); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /Warning processing types: .* "not-supported-resource-type"/ }, + ], + forbidden: [{ message: /JavaScript Error: "ChannelWrapper.matches/ }], + }); + info("No ChannelWrapper.matches errors have been logged"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js new file mode 100644 index 0000000000..af0d8594f4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); + +add_task(async function test_invalid_urls_in_webRequest_filter() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "https://example.com/*"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener(() => {}, { + urls: ["htt:/example.com/*"], + types: ["main_frame"], + }); + }, + }); + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + await extension.unload(); + }); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: /ExtensionError: Invalid url pattern: htt:\/example.com\/*/, + }, + ], + }, + true + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js new file mode 100644 index 0000000000..b63d14cd16 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js @@ -0,0 +1,57 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/HELLO", (req, res) => { + res.write("BYE"); +}); + +add_task(async function request_from_extension_page() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/", "webRequest", "webRequestBlocking"], + }, + files: { + "tab.html": `<!DOCTYPE html><script src="tab.js"></script>`, + "tab.js": async function() { + browser.webRequest.onHeadersReceived.addListener( + details => { + let { responseHeaders } = details; + responseHeaders.push({ + name: "X-Added-by-Test", + value: "TheValue", + }); + return { responseHeaders }; + }, + { + urls: ["http://example.com/HELLO"], + }, + ["blocking", "responseHeaders"] + ); + + // Ensure that listener is registered (workaround for bug 1300234). + await browser.runtime.getPlatformInfo(); + + let response = await fetch("http://example.com/HELLO"); + browser.test.assertEq( + "TheValue", + response.headers.get("X-added-by-test"), + "expected response header from webRequest listener" + ); + browser.test.assertEq( + await response.text(), + "BYE", + "Expected response from server" + ); + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html`, + { extension } + ); + await extension.awaitMessage("done"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js new file mode 100644 index 0000000000..425d83560d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js @@ -0,0 +1,99 @@ +"use strict"; + +const HOSTS = new Set(["example.com", "example.org"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +function getExtension(permission = "<all_urls>") { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", permission], + }, + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + details.requestHeaders.push({ name: "Host", value: "example.org" }); + return { requestHeaders: details.requestHeaders }; + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + }, + }); +} + +add_task(async function test_host_header_accepted() { + let extension = getExtension(); + await extension.startup(); + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.org", "Host header was set on request"); + + await extension.unload(); +}); + +add_task(async function test_host_header_denied() { + let extension = getExtension(`${BASE_URL}/`); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.com", "Host header was not set on request"); + + await extension.unload(); +}); + +add_task(async function test_host_header_restricted() { + Services.prefs.setCharPref( + "extensions.webextensions.restrictedDomains", + "example.org" + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextensions.restrictedDomains"); + }); + + let extension = getExtension(); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.com", "Host header was not set on request"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js new file mode 100644 index 0000000000..cc84791aaf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js @@ -0,0 +1,81 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_incognito_webrequest_access() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + let pb_extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertTrue(details.incognito, "incognito flag is set"); + }, + { urls: ["<all_urls>"], incognito: true }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("webRequest.spanning"); + }, + { urls: ["<all_urls>"], incognito: false }, + ["blocking"] + ); + }, + }); + await pb_extension.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("webRequest"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + }); + // Load non-incognito extension to check that private requests are invisible to it. + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitFinish("webRequest"); + await pb_extension.awaitFinish("webRequest.spanning"); + await contentPage.close(); + + await pb_extension.unload(); + await extension.unload(); + + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js new file mode 100644 index 0000000000..06dd0f54ef --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js @@ -0,0 +1,214 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.net", "example.com"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +const pageContent = `<!DOCTYPE html> + <script id="script1" src="/data/file_script_good.js"></script> + <script id="script3" src="//example.com/data/file_script_bad.js"></script> + <img id="img1" src='/data/file_image_good.png'> + <img id="img3" src='//example.com/data/file_image_good.png'> +`; + +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (request.queryString) { + response.setHeader( + "Content-Security-Policy", + decodeURIComponent(request.queryString) + ); + } + response.write(pageContent); +}); + +let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://example.net/*"], + }, + background() { + let csp_value = undefined; + browser.test.onMessage.addListener(function(msg, expectedCount) { + csp_value = msg; + browser.test.sendMessage("csp-set"); + }); + browser.webRequest.onHeadersReceived.addListener( + e => { + browser.test.log(`onHeadersReceived ${e.requestId} ${e.url}`); + if (csp_value === undefined) { + browser.test.assertTrue(false, "extension called before CSP was set"); + } + if (csp_value !== null) { + e.responseHeaders = e.responseHeaders.filter( + i => i.name.toLowerCase() != "content-security-policy" + ); + if (csp_value !== "") { + e.responseHeaders.push({ + name: "Content-Security-Policy", + value: csp_value, + }); + } + } + return { responseHeaders: e.responseHeaders }; + }, + { urls: ["*://example.net/*"] }, + ["blocking", "responseHeaders"] + ); + }, +}; + +/** + * Test a combination of Content Security Policies against first/third party images/scripts. + * @param {string} site_csp The CSP to be sent by the site, or null. + * @param {string} ext1_csp The CSP to be sent by the first extension, + * "" to remove the header, or null to not modify it. + * @param {string} ext2_csp The CSP to be sent by the first extension, + * "" to remove the header, or null to not modify it. + * @param {Object} expect Object containing information which resources are expected to be loaded. + * @param {Object} expect.img1_loaded image from a first party origin. + * @param {Object} expect.img3_loaded image from a third party origin. + * @param {Object} expect.script1_loaded script from a first party origin. + * @param {Object} expect.script3_loaded script from a third party origin. + */ +async function test_csp(site_csp, ext1_csp, ext2_csp, expect) { + let extension1 = await ExtensionTestUtils.loadExtension(extensionData); + let extension2 = await ExtensionTestUtils.loadExtension(extensionData); + await extension1.startup(); + await extension2.startup(); + extension1.sendMessage(ext1_csp); + extension2.sendMessage(ext2_csp); + await extension1.awaitMessage("csp-set"); + await extension2.awaitMessage("csp-set"); + + let csp_value = encodeURIComponent(site_csp || ""); + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://example.net/?${csp_value}` + ); + let results = await contentPage.spawn(null, async () => { + let img1 = this.content.document.getElementById("img1"); + let img3 = this.content.document.getElementById("img3"); + return { + img1_loaded: img1.complete && img1.naturalWidth > 0, + img3_loaded: img3.complete && img3.naturalWidth > 0, + // Note: "good" and "bad" are just placeholders; they don't mean anything. + script1_loaded: !!this.content.document.getElementById("good"), + script3_loaded: !!this.content.document.getElementById("bad"), + }; + }); + + await contentPage.close(); + await extension1.unload(); + await extension2.unload(); + + let action = { + true: "loaded", + false: "blocked", + }; + + info(`test_csp: From "${site_csp}" to "${ext1_csp}" to "${ext2_csp}"`); + + equal( + expect.img1_loaded, + results.img1_loaded, + `expected first party image to be ${action[expect.img1_loaded]}` + ); + equal( + expect.img3_loaded, + results.img3_loaded, + `expected third party image to be ${action[expect.img3_loaded]}` + ); + equal( + expect.script1_loaded, + results.script1_loaded, + `expected first party script to be ${action[expect.script1_loaded]}` + ); + equal( + expect.script3_loaded, + results.script3_loaded, + `expected third party script to be ${action[expect.script3_loaded]}` + ); +} + +add_task(async function test_webRequest_mergecsp() { + await test_csp("default-src *", "script-src 'none'", null, { + img1_loaded: true, + img3_loaded: true, + script1_loaded: false, + script3_loaded: false, + }); + await test_csp(null, "script-src 'none'", null, { + img1_loaded: true, + img3_loaded: true, + script1_loaded: false, + script3_loaded: false, + }); + await test_csp("default-src *", "script-src 'none'", "img-src 'none'", { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + }); + await test_csp(null, "script-src 'none'", "img-src 'none'", { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + }); + await test_csp( + "default-src *", + "img-src example.com", + "img-src example.org", + { + img1_loaded: false, + img3_loaded: false, + script1_loaded: true, + script3_loaded: true, + } + ); +}); + +add_task(async function test_remove_and_replace_csp() { + // CSP removed, CSP added. + await test_csp("img-src 'self'", "", "img-src example.com", { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); + + // CSP removed, CSP added. + await test_csp("default-src 'none'", "", "img-src example.com", { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); + + // CSP replaced - regression test for bug 1635781. + await test_csp("default-src 'none'", "img-src example.com", null, { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); + + // CSP unchanged, CSP replaced - regression test for bug 1635781. + await test_csp("default-src 'none'", null, "img-src example.com", { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); + + // CSP replaced, CSP removed. + await test_csp("default-src 'none'", "img-src example.com", "", { + img1_loaded: true, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js new file mode 100644 index 0000000000..530deaa1a7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js @@ -0,0 +1,154 @@ +"use strict"; + +const PREF_DISABLE_SECURITY = + "security.turn_off_all_security_so_that_" + + "viruses_can_take_over_this_computer"; + +const HOSTS = new Set(["example.com", "example.org"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_permissions() { + function background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + if (details.url.includes("_original")) { + let redirectUrl = details.url + .replace("example.org", "example.com") + .replace("_original", "_redirected"); + return { redirectUrl }; + } + return {}; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + } + + let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + const frameScript = () => { + const messageListener = { + async receiveMessage({ target, messageName, recipient, data, name }) { + /* globals content */ + let doc = content.document; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + + let promise = new Promise(resolve => { + let listener = event => { + content.removeEventListener("message", listener); + resolve(event.data); + }; + content.addEventListener("message", listener); + }); + + iframe.setAttribute( + "src", + "http://example.com/data/file_WebRequest_permission_original.html" + ); + let result = await promise; + doc.body.removeChild(iframe); + return result; + }, + }; + + const { MessageChannel } = ChromeUtils.import( + "resource://gre/modules/MessageChannel.jsm" + ); + MessageChannel.addListener(this, "Test:Check", messageListener); + }; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await contentPage.loadFrameScript(frameScript); + + let results = await contentPage.sendMessage("Test:Check", {}); + equal( + results.page, + "redirected", + "Regular webRequest redirect works on an unprivileged page" + ); + equal( + results.script, + "redirected", + "Regular webRequest redirect works from an unprivileged page" + ); + + Services.prefs.setBoolPref(PREF_DISABLE_SECURITY, true); + Services.prefs.setBoolPref("extensions.webapi.testing", true); + Services.prefs.setBoolPref("extensions.webapi.testing.http", true); + + results = await contentPage.sendMessage("Test:Check", {}); + equal( + results.page, + "original", + "webRequest redirect fails on a privileged page" + ); + equal( + results.script, + "original", + "webRequest redirect fails from a privileged page" + ); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_no_webRequestBlocking_error() { + function background() { + const expectedError = + "Using webRequest.addListener with the blocking option " + + "requires the 'webRequestBlocking' permission."; + + const blockingEvents = [ + "onBeforeRequest", + "onBeforeSendHeaders", + "onHeadersReceived", + "onAuthRequired", + ]; + + for (let eventName of blockingEvents) { + browser.test.assertThrows( + () => { + browser.webRequest[eventName].addListener( + details => {}, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + expectedError, + `Got the expected exception for a blocking webRequest.${eventName} listener` + ); + } + } + + const extensionData = { + manifest: { permissions: ["webRequest", "<all_urls>"] }, + background, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js new file mode 100644 index 0000000000..f8d329c85b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js @@ -0,0 +1,129 @@ +"use strict"; + +// StreamFilters should be closed upon a redirect. +// +// Some redirects are already tested in other tests: +// - test_ext_webRequest_filterResponseData.js tests fetch requests. +// - test_ext_webRequest_viewsource_StreamFilter.js tests view-source documents. +// +// Usually, redirects are caught in StreamFilterParent::OnStartRequest, but due +// to the fact that AttachStreamFilter is deferred for document requests, OSR is +// not called and the cleanup is triggered from nsHttpChannel::ReleaseListeners. + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/redir", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Found"); + response.setHeader("Location", "/target"); +}); +server.registerPathHandler("/target", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); +server.registerPathHandler("/RedirectToRedir.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.write("<script>location.href='http://example.com/redir';</script>"); +}); +server.registerPathHandler("/iframeWithRedir.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.write("<iframe src='http://example.com/redir'></iframe>"); +}); + +function loadRedirectCatcherExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background() { + const closeCounts = {}; + browser.webRequest.onBeforeRequest.addListener( + details => { + let expectedError = "Channel redirected"; + if (details.type === "main_frame" || details.type === "sub_frame") { + // Message differs for the reason stated at the top of this file. + // TODO bug 1683862: Make error message more accurate. + expectedError = "Invalid request ID"; + } + + closeCounts[details.requestId] = 0; + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.disconnect(); + browser.test.fail("Unexpected filter.onstart"); + }; + filter.onerror = function() { + closeCounts[details.requestId]++; + browser.test.assertEq(expectedError, filter.error, "filter.error"); + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + browser.webRequest.onCompleted.addListener( + details => { + // filter.onerror from the redirect request should be called before + // webRequest.onCompleted of the redirection target. Regression test + // for bug 1683189. + browser.test.assertEq( + 1, + closeCounts[details.requestId], + "filter from initial, redirected request should have been closed" + ); + browser.test.log("Intentionally canceling view-source request"); + browser.test.sendMessage("req_end", details.type); + }, + { urls: ["*://*/target"] } + ); + }, + }); +} + +add_task(async function redirect_document() { + let extension = loadRedirectCatcherExtension(); + await extension.startup(); + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/redir" + ); + equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc"); + await contentPage.close(); + } + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/iframeWithRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc"); + await contentPage.close(); + } + + await extension.unload(); +}); + +// Cross-origin redirect = process switch. +add_task(async function redirect_document_cross_origin() { + let extension = loadRedirectCatcherExtension(); + await extension.startup(); + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/RedirectToRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc"); + await contentPage.close(); + } + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/iframeWithRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc"); + await contentPage.close(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js new file mode 100644 index 0000000000..e390e3348e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js @@ -0,0 +1,47 @@ +"use strict"; + +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1573456 +add_task(async function test_mozextension_page_loaded_in_extension_process() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "https://example.com/*", + ], + web_accessible_resources: ["test.html"], + }, + files: { + "test.html": '<!DOCTYPE html><script src="test.js"></script>', + "test.js": () => { + browser.test.assertTrue( + browser.webRequest, + "webRequest API should be available" + ); + + browser.test.sendMessage("test_done"); + }, + }, + background: () => { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { + redirectUrl: browser.runtime.getURL("test.html"), + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "https://example.com/redir" + ); + + await extension.awaitMessage("test_done"); + + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js new file mode 100644 index 0000000000..69238fb057 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js @@ -0,0 +1,57 @@ +"use strict"; + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +const EXTENSION_DATA = { + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + + permissions: ["webRequest", "<all_urls>"], + }, + + async background() { + browser.test.log("background script running"); + + browser.webRequest.onBeforeSendHeaders.addListener( + async details => { + browser.test.assertTrue(details.requestSize == 0, "no requestSize"); + browser.test.assertTrue(details.responseSize == 0, "no responseSize"); + browser.test.log(`details.requestSize: ${details.requestSize}`); + browser.test.log(`details.responseSize: ${details.responseSize}`); + browser.test.sendMessage("check"); + }, + { urls: ["*://*/*"] } + ); + + browser.webRequest.onCompleted.addListener( + async details => { + browser.test.assertTrue(details.requestSize > 100, "have requestSize"); + browser.test.assertTrue( + details.responseSize > 100, + "have responseSize" + ); + browser.test.log(`details.requestSize: ${details.requestSize}`); + browser.test.log(`details.responseSize: ${details.responseSize}`); + browser.test.sendMessage("done"); + }, + { urls: ["*://*/*"] } + ); + }, +}; + +add_task(async function test_request_response_size() { + let ext = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await ext.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${gServerUrl}/dummy` + ); + await ext.awaitMessage("check"); + await ext.awaitMessage("done"); + await contentPage.close(); + await ext.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js new file mode 100644 index 0000000000..d3715684f9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js @@ -0,0 +1,765 @@ +"use strict"; + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* eslint-disable no-shadow */ + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { ExtensionTestCommon } = ChromeUtils.import( + "resource://testing-common/ExtensionTestCommon.jsm" +); + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/data/file_sample.html"; + +const SEQUENTIAL = false; + +const PARTS = [ + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body>`, + "Lorem ipsum dolor sit amet, <br>", + "consectetur adipiscing elit, <br>", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>", + "Excepteur sint occaecat cupidatat non proident, <br>", + "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>", + ` + </body> + </html>`, +].map(part => `${part}\n`); + +const TIMEOUT = AppConstants.DEBUG ? 4000 : 800; + +function delay(timeout = TIMEOUT) { + return new Promise(resolve => setTimeout(resolve, timeout)); +} + +server.registerPathHandler("/slow_response.sjs", async (request, response) => { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + + await delay(); + + for (let part of PARTS) { + try { + response.write(part); + } catch (e) { + // This fails if we attempt to write data after the connection has + // been closed. + break; + } + await delay(); + } + + response.finish(); +}); + +server.registerPathHandler("/lorem.html.gz", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + "Content-Type: text/html; charset=utf-8", + false + ); + response.setHeader("Content-Encoding", "gzip", false); + + let data = await OS.File.read(do_get_file("data/lorem.html.gz").path); + response.write(String.fromCharCode(...new Uint8Array(data))); + + response.finish(); +}); + +server.registerPathHandler("/multipart", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"', + false + ); + + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting--\n"); + + response.finish(); +}); + +server.registerPathHandler("/multipart2", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"', + false + ); + + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting--\n"); + + response.finish(); +}); + +server.registerDirectory("/data/", do_get_file("data")); + +const TASKS = [ + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let decoder = new TextDecoder("utf-8"); + + browser.test.assertEq( + "uninitialized", + filter.status, + `(${num}): Got expected initial status` + ); + + filter.onstart = event => { + browser.test.assertEq( + "transferringdata", + filter.status, + `(${num}): Got expected onStart status` + ); + }; + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + let str = decoder.decode(event.data, { stream: true }); + + if (n < 3) { + browser.test.assertEq( + JSON.stringify(PARTS[n]), + JSON.stringify(str), + `(${num}): Got expected part` + ); + } + n++; + + filter.write(event.data); + + if (n == 3) { + filter.suspend(); + + browser.test.assertEq( + "suspended", + filter.status, + `(${num}): Got expected suspended status` + ); + + let fail = () => { + browser.test.fail( + `(${num}): Got unexpected data event while suspended` + ); + }; + filter.addEventListener("data", fail); + + await delay(TIMEOUT * 3); + + browser.test.assertEq( + "suspended", + filter.status, + `(${num}): Got expected suspended status` + ); + + filter.removeEventListener("data", fail); + filter.resume(); + browser.test.assertEq( + "transferringdata", + filter.status, + `(${num}): Got expected resumed status` + ); + } else if (n > 4) { + filter.disconnect(); + + filter.addEventListener("data", () => { + browser.test.fail( + `(${num}): Got unexpected data event while disconnected` + ); + }); + + browser.test.assertEq( + "disconnected", + filter.status, + `(${num}): Got expected disconnected status` + ); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let decoder = new TextDecoder("utf-8"); + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + let str = decoder.decode(event.data, { stream: true }); + + if (n < 3) { + browser.test.assertEq( + JSON.stringify(PARTS[n]), + JSON.stringify(str), + `(${num}): Got expected part` + ); + } + n++; + + filter.write(event.data); + + if (n == 3) { + filter.suspend(); + + await delay(TIMEOUT * 3); + + filter.disconnect(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let encoder = new TextEncoder("utf-8"); + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + n++; + + filter.write(event.data); + + function checkState(state) { + browser.test.assertEq( + state, + filter.status, + `(${num}): Got expected status` + ); + } + if (n == 3) { + filter.resume(); + checkState("transferringdata"); + filter.suspend(); + checkState("suspended"); + filter.suspend(); + checkState("suspended"); + filter.resume(); + checkState("transferringdata"); + filter.suspend(); + checkState("suspended"); + + await delay(TIMEOUT * 3); + + checkState("suspended"); + filter.disconnect(); + checkState("disconnected"); + + for (let method of ["suspend", "resume", "close"]) { + browser.test.assertThrows( + () => { + filter[method](); + }, + /.*/, + `(${num}): ${method}() should throw while disconnected` + ); + } + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw while disconnected` + ); + + filter.disconnect(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let encoder = new TextEncoder("utf-8"); + let decoder = new TextDecoder("utf-8"); + + filter.onstop = event => { + browser.test.fail(`(${num}): Got unexpected onStop event while closed`); + }; + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw prior to connection` + ); + + let n = 0; + filter.ondata = async event => { + n++; + + filter.write(event.data); + + browser.test.log( + `(${num}): Got part ${n}: ${JSON.stringify( + decoder.decode(event.data) + )}` + ); + + function checkState(state) { + browser.test.assertEq( + state, + filter.status, + `(${num}): Got expected status` + ); + } + if (n == 3) { + filter.close(); + + checkState("closed"); + + for (let method of ["suspend", "resume", "disconnect"]) { + browser.test.assertThrows( + () => { + filter[method](); + }, + /.*/, + `(${num}): ${method}() should throw while closed` + ); + } + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw while closed` + ); + + filter.close(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.slice(0, 3).join(""), "Got expected final HTML"); + }, + }, + { + url: "lorem.html.gz", + task(filter, resolve, num) { + let response = ""; + let decoder = new TextDecoder("utf-8"); + + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + browser.test.assertEq( + "finishedtransferringdata", + filter.status, + `(${num}): Got expected onStop status` + ); + + filter.close(); + browser.test.assertEq( + "closed", + filter.status, + `Got expected closed status` + ); + + browser.test.assertEq( + JSON.stringify(PARTS.join("")), + JSON.stringify(response), + `(${num}): Got expected response` + ); + + resolve(); + }; + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + response += str; + + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "multipart", + task(filter, resolve, num) { + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + filter.disconnect(); + resolve(); + }; + + filter.ondata = event => { + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal( + response, + "--testingtesting\n" + PARTS.join("") + "--testingtesting--\n", + "Got expected final HTML" + ); + }, + }, + { + url: "multipart2", + task(filter, resolve, num) { + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + filter.disconnect(); + resolve(); + }; + + filter.ondata = event => { + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal( + response, + "--testingtesting\n" + + PARTS.join("") + + "--testingtesting\n" + + PARTS.join("") + + "--testingtesting--\n", + "Got expected final HTML" + ); + }, + }, +]; + +function serializeTest(test, num) { + let url = `${test.url}?test_num=${num}`; + let task = ExtensionTestCommon.serializeFunction(test.task); + + return `{url: ${JSON.stringify(url)}, task: ${task}}`; +} + +add_task(async function() { + function background(TASKS) { + async function runTest(test, num, details) { + browser.test.log(`Running test #${num}: ${details.url}`); + + let filter = browser.webRequest.filterResponseData(details.requestId); + + try { + await new Promise(resolve => { + test.task(filter, resolve, num, details); + }); + } catch (e) { + browser.test.fail( + `Task #${num} threw an unexpected exception: ${e} :: ${e.stack}` + ); + } + + browser.test.log(`Finished test #${num}: ${details.url}`); + browser.test.sendMessage(`finished-${num}`); + } + + browser.webRequest.onBeforeRequest.addListener( + details => { + for (let [num, test] of TASKS.entries()) { + if (details.url.endsWith(test.url)) { + runTest(test, num, details); + break; + } + } + }, + { + urls: ["http://example.com/*?test_num=*"], + }, + ["blocking"] + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: ` + const PARTS = ${JSON.stringify(PARTS)}; + const TIMEOUT = ${TIMEOUT}; + + ${delay} + + (${background})([${TASKS.map(serializeTest)}]) + `, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + async function runTest(test, num) { + let url = `${BASE_URL}/${test.url}?test_num=${num}`; + + let body = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + + await extension.awaitMessage(`finished-${num}`); + + info(`Verifying test #${num}: ${url}`); + await test.verify(body); + } + + if (SEQUENTIAL) { + for (let [num, test] of TASKS.entries()) { + await runTest(test, num); + } + } else { + await Promise.all(TASKS.map(runTest)); + } + + await extension.unload(); +}); + +// Test that registering a listener for a cached response does not cause a crash. +add_task(async function test_cachedResponse() { + if (AppConstants.platform === "android") { + return; + } + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onHeadersReceived.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = event => { + filter.close(); + }; + filter.ondata = event => { + filter.write(event.data); + }; + + if (data.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await extension.awaitMessage("from-cache"); + + await extension.unload(); +}); + +// Test that finishing transferring data doesn't overwrite an existing closing/closed state. +add_task(async function test_late_close() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = event => { + browser.test.fail("Should not receive onstop after close()"); + browser.test.assertEq( + "closed", + filter.status, + "Filter status should still be 'closed'" + ); + browser.test.assertThrows(() => { + filter.close(); + }); + }; + filter.ondata = event => { + filter.write(event.data); + filter.close(); + + browser.test.sendMessage(`done-${data.url}`); + }; + }, + { + urls: ["http://example.com/*/file_sample.html?*"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + // This issue involves a race, so several requests in parallel to increase + // the chances of triggering it. + let urls = []; + for (let i = 0; i < 32; i++) { + urls.push(`${BASE_URL}/data/file_sample.html?r=${Math.random()}`); + } + + await Promise.all( + urls.map(url => ExtensionTestUtils.fetch(FETCH_ORIGIN, url)) + ); + await Promise.all(urls.map(url => extension.awaitMessage(`done-${url}`))); + + await extension.unload(); +}); + +add_task(async function test_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.assertEq( + undefined, + browser.webRequest.filterResponseData, + "filterResponseData is undefined without blocking permissions" + ); + }, + + manifest: { + permissions: ["webRequest", "http://example.com/"], + }, + }); + + await extension.startup(); + await extension.unload(); +}); + +add_task(async function test_invalidId() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let filter = browser.webRequest.filterResponseData("34159628"); + + await new Promise(resolve => { + filter.onerror = resolve; + }); + + browser.test.assertEq( + "Invalid request ID", + filter.error, + "Got expected error" + ); + + browser.test.notifyPass("invalid-request-id"); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("invalid-request-id"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js new file mode 100644 index 0000000000..e40bc4f8b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js @@ -0,0 +1,308 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler( + "/file_webrequestblocking_set_cookie.html", + (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Set-Cookie", "reqcookie=reqvalue", false); + response.write("<!DOCTYPE html><html></html>"); + } +); + +add_task(async function test_modifying_cookies_from_onHeadersReceived() { + async function background() { + /** + * Check that all the cookies described by `prefixes` are in the cookie jar. + * + * @param {Array.string} prefixes + * Zero or more prefixes, describing cookies that are expected to be set + * in the current cookie jar. Each prefix describes both a cookie + * name and corresponding value. For example, if the string "ext" + * is passed as an argument, then this function expects to see + * a cookie called "extcookie" and corresponding value of "extvalue". + */ + async function checkCookies(prefixes) { + const numPrefixes = prefixes.length; + const currentCookies = await browser.cookies.getAll({}); + browser.test.assertEq( + numPrefixes, + currentCookies.length, + `${numPrefixes} cookies were set` + ); + + for (let cookiePrefix of prefixes) { + let cookieName = `${cookiePrefix}cookie`; + let expectedCookieValue = `${cookiePrefix}value`; + let fetchedCookie = await browser.cookies.getAll({ name: cookieName }); + browser.test.assertEq( + 1, + fetchedCookie.length, + `Found 1 cookie with name "${cookieName}"` + ); + browser.test.assertEq( + expectedCookieValue, + fetchedCookie[0] && fetchedCookie[0].value, + `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"` + ); + } + } + + function awaitMessage(expectedMsg) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg) { + if (msg === expectedMsg) { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }); + }); + } + + /** + * Opens the given test file as a content page. + * + * @param {string} filename + * The name of a html file relative to the test server root. + * + * @returns {Promise} + */ + function openContentPage(filename) { + let promise = awaitMessage("url-loaded"); + browser.test.sendMessage( + "load-url", + `http://example.com/${filename}?nocache=${Math.random()}` + ); + return promise; + } + + /** + * Tests that expected cookies are in the cookie jar after opening a file. + * + * @param {string} filename + * The name of a html file in the + * "toolkit/components/extensions/test/mochitest" directory. + * @param {?Array.string} prefixes + * Zero or more prefixes, describing cookies that are expected to be set + * in the current cookie jar. Each prefix describes both a cookie + * name and corresponding value. For example, if the string "ext" + * is passed as an argument, then this function expects to see + * a cookie called "extcookie" and corresponding value of "extvalue". + * If undefined, then no checks are automatically performed, and the + * caller should provide a callback to perform the checks. + * @param {?Function} callback + * An optional async callback function that, if provided, will be called + * with an object that contains windowId and tabId parameters. + * Callers can use this callback to apply extra tests about the state of + * the cookie jar, or to query the state of the opened page. + */ + async function testCookiesWithFile(filename, prefixes, callback) { + await browser.browsingData.removeCookies({}); + await openContentPage(filename); + + if (prefixes !== undefined) { + await checkCookies(prefixes); + } + + if (callback !== undefined) { + await callback(); + } + let promise = awaitMessage("url-unloaded"); + browser.test.sendMessage("unload-url"); + await promise; + } + + const filter = { + urls: ["<all_urls>"], + types: ["main_frame", "sub_frame"], + }; + + const headersReceivedInfoSpec = ["blocking", "responseHeaders"]; + + const onHeadersReceived = details => { + details.responseHeaders.push({ + name: "Set-Cookie", + value: "extcookie=extvalue", + }); + + return { + responseHeaders: details.responseHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + onHeadersReceived, + filter, + headersReceivedInfoSpec + ); + + // First, perform a request that should not set any cookies, and check + // that the cookie the extension sets is the only cookie in the + // cookie jar. + await testCookiesWithFile("data/file_sample.html", ["ext"]); + + // Next, perform a request that will set on cookie (reqcookie=reqvalue) + // and check that two cookies wind up in the cookie jar (the request + // set cookie, and the extension set cookie). + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + "req", + ]); + + // Third, register another onHeadersReceived handler that also + // sets a cookie (thirdcookie=thirdvalue), to make sure modifications from + // multiple onHeadersReceived listeners are merged correctly. + const thirdOnHeadersRecievedListener = details => { + details.responseHeaders.push({ + name: "Set-Cookie", + value: "thirdcookie=thirdvalue", + }); + + browser.test.log(JSON.stringify(details.responseHeaders)); + + return { + responseHeaders: details.responseHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + thirdOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + "req", + "third", + ]); + browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived); + browser.webRequest.onHeadersReceived.removeListener( + thirdOnHeadersRecievedListener + ); + + // Fourth, test to make sure that extensions can remove cookies + // using onHeadersReceived too, by 1. making a request that + // sets a cookie (reqcookie=reqvalue), 2. having the extension remove + // that cookie by removing that header, and 3. adding a new cookie + // (extcookie=extvalue). + const fourthOnHeadersRecievedListener = details => { + // Remove the cookie set by the request (reqcookie=reqvalue). + const newHeaders = details.responseHeaders.filter( + cookie => cookie.name !== "set-cookie" + ); + + // And then add a new cookie in its place (extcookie=extvalue). + newHeaders.push({ + name: "Set-Cookie", + value: "extcookie=extvalue", + }); + + return { + responseHeaders: newHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + fourthOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + ]); + browser.webRequest.onHeadersReceived.removeListener( + fourthOnHeadersRecievedListener + ); + + // Fifth, check that extensions are able to overwrite headers set by + // pages. In this test, make a request that will set "reqcookie=reqvalue", + // and add a listener that sets "reqcookie=changedvalue". Check + // to make sure that the cookie jar contains "reqcookie=changedvalue" + // and not "reqcookie=reqvalue". + const fifthOnHeadersRecievedListener = details => { + // Remove the cookie set by the request (reqcookie=reqvalue). + const newHeaders = details.responseHeaders.filter( + cookie => cookie.name !== "set-cookie" + ); + + // And then add a new cookie in its place (reqcookie=changedvalue). + newHeaders.push({ + name: "Set-Cookie", + value: "reqcookie=changedvalue", + }); + + return { + responseHeaders: newHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + fifthOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + + await testCookiesWithFile( + "file_webrequestblocking_set_cookie.html", + undefined, + async () => { + const currentCookies = await browser.cookies.getAll({}); + browser.test.assertEq(1, currentCookies.length, `1 cookie was set`); + + const cookieName = "reqcookie"; + const expectedCookieValue = "changedvalue"; + const fetchedCookie = await browser.cookies.getAll({ + name: cookieName, + }); + + browser.test.assertEq( + 1, + fetchedCookie.length, + `Found 1 cookie with name "${cookieName}"` + ); + browser.test.assertEq( + expectedCookieValue, + fetchedCookie[0] && fetchedCookie[0].value, + `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"` + ); + } + ); + browser.webRequest.onHeadersReceived.removeListener( + fifthOnHeadersRecievedListener + ); + + browser.test.notifyPass("cookie modifying extension"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "browsingData", + "cookies", + "webNavigation", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }, + background, + }); + + let contentPage = null; + extension.onMessage("load-url", async url => { + ok(!contentPage, "Should have no content page to unload"); + contentPage = await ExtensionTestUtils.loadContentPage(url); + extension.sendMessage("url-loaded"); + }); + extension.onMessage("unload-url", async () => { + await contentPage.close(); + contentPage = null; + extension.sendMessage("url-unloaded"); + }); + + await extension.startup(); + await extension.awaitFinish("cookie modifying extension"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js new file mode 100644 index 0000000000..0528d97298 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js @@ -0,0 +1,603 @@ +"use strict"; + +// Delay loading until createAppInfo is called and setup. +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +// The app and platform version here should be >= of the version set in the extensions.webExtensionsMinPlatformVersion preference, +// otherwise test_persistent_listener_after_staged_update will fail because no compatible updates will be found. +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION; +Services.prefs.setIntPref("extensions.enabledScopes", scopes); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-page-event", "start-background-page"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +async function testPersistentRequestStartup(extension, events, expect) { + equal( + events.get("background-page-event"), + expect.background, + "Should have gotten a background page event" + ); + equal( + events.get("start-background-page"), + false, + "Background page should not be started" + ); + + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await ExtensionParent.browserPaintedPromise; + + equal( + events.get("start-background-page"), + expect.delayedStart, + "Should have gotten start-background-page event" + ); + + if (expect.request) { + await extension.awaitMessage("got-request"); + ok(true, "Background page loaded and received webRequest event"); + } +} + +// Test that a non-blocking listener during startup does not immediately +// start the background page, but the event is queued until the background +// page is started. +add_task(async function test_1() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["webRequest", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + }, + }); + + await extension.startup(); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: true, + delayedStart: true, + request: true, + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +// Tests that filters are handled properly: if we have a blocking listener +// with a filter, a request that does not match the filter does not get +// suspended and does not start the background page. +add_task(async function test_2() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://test1.example.com/", + ], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail("Listener should not have been called"); + }, + { urls: ["http://test1.example.com/*"] }, + ["blocking"] + ); + + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: false, + delayedStart: false, + request: false, + }); + + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + await extension.awaitMessage("ready"); + + await extension.unload(); + await promiseShutdownManager(); +}); + +// Tests that moving permission to optional retains permission and that the +// persistent listeners are used as expected. +add_task(async function test_persistent_listener_after_sideload_upgrade() { + let id = "permission-sideload-upgrade@test"; + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { gecko: { id } }, + permissions: ["webRequest", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + }, + }; + let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + + let extension = ExtensionTestUtils.expectExtension(id); + await AddonTestUtils.manuallyInstall(xpi); + await promiseStartupManager(); + await extension.awaitStartup(); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + + await promiseShutdownManager(); + + // Prepare a sideload update for the extension. + extensionData.manifest.version = "2.0"; + extensionData.manifest.permissions = ["http://example.com/"]; + extensionData.manifest.optional_permissions = ["webRequest"]; + xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + await AddonTestUtils.manuallyInstall(xpi); + + ExtensionParent._resetStartupPromises(); + await promiseStartupManager(); + await extension.awaitStartup(); + let events = trackEvents(extension); + + // Verify webRequest permission. + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("webRequest"), "addon webRequest permission added"); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: true, + delayedStart: true, + request: true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +// Utility to install builtin addon +async function installBuiltinExtension(extensionData) { + let xpi = await AddonTestUtils.createTempWebExtensionFile(extensionData); + + // The built-in location requires a resource: URL that maps to a + // jar: or file: URL. This would typically be something bundled + // into omni.ja but for testing we just use a temp file. + let base = Services.io.newURI(`jar:file:${xpi.path}!/`); + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitution("ext-test", base); + return AddonManager.installBuiltinAddon("resource://ext-test/"); +} + +function promisePostponeInstall(install) { + return new Promise((resolve, reject) => { + let listener = { + onInstallFailed: () => { + install.removeListener(listener); + reject(new Error("extension installation should not have failed")); + }, + onInstallEnded: () => { + install.removeListener(listener); + reject( + new Error( + `extension installation should not have ended for ${install.addon.id}` + ) + ); + }, + onInstallPostponed: () => { + install.removeListener(listener); + resolve(); + }, + }; + + install.addListener(listener); + install.install(); + }); +} + +// Tests that moving permission to optional retains permission and that the +// persistent listeners are used as expected. +add_task( + async function test_persistent_listener_after_builtin_location_upgrade() { + let id = "permission-builtin-upgrade@test"; + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { gecko: { id } }, + permissions: ["webRequest", "http://example.com/"], + }, + + async background() { + browser.runtime.onUpdateAvailable.addListener(() => { + browser.test.sendMessage("postponed"); + }); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + }, + }; + await promiseStartupManager(); + // If we use an extension wrapper via ExtensionTestUtils.expectExtension + // it will continue to handle messages even after the update, resulting + // in errors when it receives additional messages without any awaitMessage. + let promiseExtension = AddonTestUtils.promiseWebExtensionStartup(id); + await installBuiltinExtension(extensionData); + let extv1 = await promiseExtension; + + // Prepare an update for the extension. + extensionData.manifest.version = "2.0"; + let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + let install = await AddonManager.getInstallForFile(xpi); + + // Install the update and wait for the onUpdateAvailable event to complete. + let promiseUpdate = new Promise(resolve => + extv1.once("test-message", (kind, msg) => { + if (msg == "postponed") { + resolve(); + } + }) + ); + await Promise.all([promisePostponeInstall(install), promiseUpdate]); + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + ExtensionParent._resetStartupPromises(); + let extension = ExtensionTestUtils.expectExtension(id); + await promiseStartupManager(); + await extension.awaitStartup(); + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: true, + delayedStart: true, + request: true, + }); + + await extension.unload(); + + // remove the builtin addon which will have restarted now. + let addon = await AddonManager.getAddonByID(id); + await addon.uninstall(); + + await promiseShutdownManager(); + } +); + +// Tests that moving permission to optional during a staged upgrade retains permission +// and that the persistent listeners are used as expected. +add_task(async function test_persistent_listener_after_staged_upgrade() { + AddonManager.checkUpdateSecurity = false; + let id = "persistent-staged-upgrade@test"; + + // register an update file. + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + "persistent-staged-upgrade@test": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart.xpi", + }, + ], + }, + }, + }); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + applications: { + gecko: { id, update_url: `http://example.com/test_update.json` }, + }, + permissions: ["http://example.com/"], + optional_permissions: ["webRequest"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details && details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + } + }); + }, + }; + + // Prepare the update first. + server.registerFile( + `/addons/test_settings_staged_restart.xpi`, + AddonTestUtils.createTempWebExtensionFile(extensionData) + ); + + // Prepare the extension that will be updated. + extensionData.manifest.version = "1.0"; + extensionData.manifest.permissions = ["webRequest", "http://example.com/"]; + delete extensionData.manifest.optional_permissions; + + await promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + ok(true, "Initial version received webRequest event"); + + let addon = await AddonManager.getAddonByID(id); + Assert.equal(addon.version, "1.0", "1.0 is loaded"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal( + install.state, + AddonManager.STATE_POSTPONED, + "update is staged for install" + ); + await extension.awaitMessage("delay"); + + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + ExtensionParent._resetStartupPromises(); + await promiseStartupManager(); + await extension.awaitStartup(); + let events = trackEvents(extension); + + // Verify webRequest permission. + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("webRequest"), "addon webRequest permission added"); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: true, + delayedStart: true, + request: true, + }); + + await extension.unload(); + await promiseShutdownManager(); + AddonManager.checkUpdateSecurity = true; +}); + +// Tests that removing the permission releases the persistent listener. +add_task(async function test_persistent_listener_after_permission_removal() { + let id = "persistent-staged-remove@test"; + + // register an update file. + AddonTestUtils.registerJSON(server, "/test_remove.json", { + addons: { + "persistent-staged-remove@test": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_remove.xpi", + }, + ], + }, + }, + }); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + applications: { + gecko: { id, update_url: `http://example.com/test_remove.json` }, + }, + permissions: ["tabs", "http://example.com/"], + }, + + background() { + browser.test.sendMessage("loaded"); + }, + }; + + // Prepare the update first. + server.registerFile( + `/addons/test_settings_staged_remove.xpi`, + AddonTestUtils.createTempWebExtensionFile(extensionData) + ); + + await promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { id, update_url: `http://example.com/test_remove.json` }, + }, + permissions: ["webRequest", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details && details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + } + }); + }, + }); + + await extension.startup(); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + ok(true, "Initial version received webRequest event"); + + let addon = await AddonManager.getAddonByID(id); + Assert.equal(addon.version, "1.0", "1.0 is loaded"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal( + install.state, + AddonManager.STATE_POSTPONED, + "update is staged for install" + ); + await extension.awaitMessage("delay"); + + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + await promiseStartupManager(); + let events = trackEvents(extension); + await extension.awaitStartup(); + + // Verify webRequest permission. + let policy = WebExtensionPolicy.getByID(id); + ok( + !policy.hasPermission("webRequest"), + "addon webRequest permission removed" + ); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: false, + delayedStart: false, + request: false, + }); + + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + + await extension.awaitMessage("loaded"); + ok(true, "Background page loaded"); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js new file mode 100644 index 0000000000..c8c18fcf19 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js @@ -0,0 +1,84 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +// Test that a blocking listener that uses filterResponseData() works +// properly (i.e., that the delayed call to registerTraceableChannel +// works properly). +add_task(async function test_StreamFilter_at_restart() { + const DATA = `<!DOCTYPE html> +<html> +<body> + <h1>This is a modified page</h1> +</body> +</html>`; + + function background(data) { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstop = () => { + let encoded = new TextEncoder("utf-8").encode(data); + filter.write(encoded); + filter.close(); + }; + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + } + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background: `(${background})(${uneval(DATA)})`, + }); + + await extension.startup(); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let dataPromise = ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + let data = await dataPromise; + + equal( + data, + DATA, + "Stream filter was properly installed for a load during startup" + ); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js new file mode 100644 index 0000000000..296bee3685 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js @@ -0,0 +1,49 @@ +"use strict"; + +const BASE = "http://example.com/data/"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_stylesheet_cache() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + const SHEET_URI = "http://example.com/data/file_stylesheet_cache.css"; + let firstFound = false; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + details.url, + firstFound ? SHEET_URI + "?2" : SHEET_URI + ); + firstFound = true; + browser.test.sendMessage("stylesheet found"); + }, + { urls: ["<all_urls>"], types: ["stylesheet"] }, + ["blocking"] + ); + }, + }); + + await extension.startup(); + + let cp = await ExtensionTestUtils.loadContentPage( + BASE + "file_stylesheet_cache.html" + ); + + await extension.awaitMessage("stylesheet found"); + + // Need to use the same ContentPage so that the remote process the page ends + // up in is the same. + await cp.loadURL(BASE + "file_stylesheet_cache_2.html"); + + await extension.awaitMessage("stylesheet found"); + + await cp.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js new file mode 100644 index 0000000000..48505c9a1b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js @@ -0,0 +1,294 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_suspend() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + // Make sure that returning undefined or a promise that resolves to + // undefined does not break later handlers. + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + return Promise.resolve(); + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + let requestHeaders = details.requestHeaders.concat({ + name: "Foo", + value: "Bar", + }); + + return new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, 500); + }).then(() => { + return { requestHeaders }; + }); + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + }, + }); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal( + headers.foo, + "Bar", + "Request header was correctly set on suspended request" + ); + + await extension.unload(); +}); + +// Test that requests that were canceled while suspended for a blocking +// listener are correctly resumed. +add_task(async function test_error_resume() { + let observer = channel => { + if ( + channel instanceof Ci.nsIHttpChannel && + channel.URI.spec === "http://example.com/dummy" + ) { + Services.obs.removeObserver(observer, "http-on-before-connect"); + + // Wait until the next tick to make sure this runs after WebRequest observers. + Promise.resolve().then(() => { + channel.cancel(Cr.NS_BINDING_ABORTED); + }); + } + }; + + Services.obs.addObserver(observer, "http-on-before-connect"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders({url: ${details.url}})`); + + if (details.url === "http://example.com/dummy") { + browser.test.sendMessage("got-before-send-headers"); + } + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred({url: ${details.url}})`); + + if (details.url === "http://example.com/dummy") { + browser.test.sendMessage("got-error-occurred"); + } + }, + { urls: ["<all_urls>"] } + ); + }, + }); + + await extension.startup(); + + try { + await ExtensionTestUtils.fetch(FETCH_ORIGIN, `${BASE_URL}/dummy`); + ok(false, "Fetch should have failed."); + } catch (e) { + ok(true, "Got expected error."); + } + + await extension.awaitMessage("got-before-send-headers"); + await extension.awaitMessage("got-error-occurred"); + + // Wait for the next tick so the onErrorRecurred response can be + // processed before shutting down the extension. + await new Promise(resolve => executeSoon(resolve)); + + await extension.unload(); +}); + +// Test that response header modifications take effect before onStartRequest fires. +add_task(async function test_set_responseHeaders() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background() { + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.log(`onHeadersReceived({url: ${details.url}})`); + + details.responseHeaders.push({ name: "foo", value: "bar" }); + + return { responseHeaders: details.responseHeaders }; + }, + { urls: ["http://example.com/?modify_headers"] }, + ["blocking", "responseHeaders"] + ); + }, + }); + + await extension.startup(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + let resolveHeaderPromise; + let headerPromise = new Promise(resolve => { + resolveHeaderPromise = resolve; + }); + { + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + let ssm = Services.scriptSecurityManager; + + let channel = NetUtil.newChannel({ + uri: "http://example.com/?modify_headers", + loadingPrincipal: ssm.createContentPrincipalFromOrigin( + "http://example.com" + ), + contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + }); + + channel.asyncOpen({ + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onStartRequest(request) { + request.QueryInterface(Ci.nsIHttpChannel); + + try { + resolveHeaderPromise(request.getResponseHeader("foo")); + } catch (e) { + resolveHeaderPromise(null); + } + request.cancel(Cr.NS_BINDING_ABORTED); + }, + + onStopRequest() {}, + + onDataAvailable() { + throw new Components.Exception("", Cr.NS_ERROR_FAILURE); + }, + }); + } + + let headerValue = await headerPromise; + equal(headerValue, "bar", "Expected Foo header value"); + + await extension.unload(); +}); + +// Test that exceptions raised from a blocking webRequest listener that returns +// a promise are logged as expected. +add_task(async function test_logged_error_on_promise_result() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + async function onBeforeRequest() { + throw new Error("Expected webRequest exception from a promise result"); + } + + let exceptionRaised = false; + + browser.webRequest.onBeforeRequest.addListener( + () => { + if (exceptionRaised) { + return; + } + + // We only need to raise the exception once. + exceptionRaised = true; + return onBeforeRequest(); + }, + { + urls: ["http://example.com/*"], + types: ["main_frame"], + }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + () => { + browser.test.sendMessage("web-request-event-received"); + }, + { + urls: ["http://example.com/*"], + types: ["main_frame"], + }, + ["blocking"] + ); + }, + }); + + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await extension.awaitMessage("web-request-event-received"); + await contentPage.close(); + }); + + ok( + messages.some(msg => + /Expected webRequest exception from a promise result/.test(msg.message) + ), + "Got expected console message" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js new file mode 100644 index 0000000000..9c2296c7da --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js @@ -0,0 +1,33 @@ +"use strict"; + +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +/** + * If this test fails, likely nsIClassifiedChannel has added or changed a + * CLASSIFIED_* flag. Those changes must be in sync with + * ChannelWrapper.webidl/cpp and the web_request.json schema file. + */ +add_task(async function test_webrequest_url_classification_enum() { + // use normalizeManifest to get the schema loaded. + await ExtensionTestUtils.normalizeManifest({ permissions: ["webRequest"] }); + + let ns = Schemas.getNamespace("webRequest"); + let schema_enum = ns.get("UrlClassificationFlags").enumeration; + ok( + !!schema_enum.length, + `UrlClassificationFlags: ${JSON.stringify(schema_enum)}` + ); + + let prefix = /^(?:CLASSIFIED_)/; + let entries = 0; + for (let c of Object.keys(Ci.nsIClassifiedChannel).filter(name => + prefix.test(name) + )) { + let entry = c.replace(prefix, "").toLowerCase(); + if (!entry.startsWith("socialtracking")) { + ok(schema_enum.includes(entry), `schema ${entry} is in IDL`); + entries++; + } + } + equal(schema_enum.length, entries, "same number of entries"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js new file mode 100644 index 0000000000..9710aa5990 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js @@ -0,0 +1,41 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_userContextId_webrequest() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertEq( + details.cookieStoreId, + "firefox-container-2", + "cookieStoreId is set" + ); + browser.test.notifyPass("webRequest"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 2 } + ); + await extension.awaitFinish("webRequest"); + + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js new file mode 100644 index 0000000000..35b713e59b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js @@ -0,0 +1,95 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_webRequest_viewsource() { + function background(serverPort) { + browser.proxy.onRequest.addListener( + details => { + if (details.url === `http://example.com:${serverPort}/dummy`) { + browser.test.assertTrue( + true, + "viewsource protocol worked in proxy request" + ); + browser.test.sendMessage("proxied"); + } + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + `http://example.com:${serverPort}/redirect`, + details.url, + "viewsource protocol worked in webRequest" + ); + browser.test.sendMessage("viewed"); + return { redirectUrl: `http://example.com:${serverPort}/dummy` }; + }, + { urls: ["http://example.com/redirect"] }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + `http://example.com:${serverPort}/dummy`, + details.url, + "viewsource protocol worked in webRequest" + ); + browser.test.sendMessage("redirected"); + return { cancel: true }; + }, + { urls: ["http://example.com/dummy"] }, + ["blocking"] + ); + + browser.webRequest.onCompleted.addListener( + details => { + // If cancel fails we get onCompleted. + browser.test.fail("onCompleted received"); + }, + { urls: ["http://example.com/dummy"] } + ); + + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.assertEq( + details.error, + "NS_ERROR_ABORT", + "request cancelled" + ); + browser.test.sendMessage("cancelled"); + }, + { urls: ["http://example.com/dummy"] } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${server.identity.primaryPort})`, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `view-source:http://example.com:${server.identity.primaryPort}/redirect` + ); + + await Promise.all([ + extension.awaitMessage("proxied"), + extension.awaitMessage("viewed"), + extension.awaitMessage("redirected"), + extension.awaitMessage("cancelled"), + ]); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js new file mode 100644 index 0000000000..ccb46eb4db --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js @@ -0,0 +1,144 @@ +"use strict"; + +const server = createHttpServer(); +const BASE_URL = `http://127.0.0.1:${server.identity.primaryPort}`; + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +server.registerPathHandler("/redir", (request, response) => { + response.setStatusLine(request.httpVersion, 303, "See Other"); + response.setHeader("Location", `${BASE_URL}/dummy`); +}); + +async function testViewSource(viewSourceUrl) { + function background(BASE_URL) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq(`${BASE_URL}/dummy`, details.url, "expected URL"); + browser.test.assertEq("main_frame", details.type, "details.type"); + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.write(new TextEncoder().encode("PREFIX_")); + }; + filter.ondata = event => { + filter.write(event.data); + }; + filter.onstop = () => { + filter.write(new TextEncoder().encode("_SUFFIX")); + filter.disconnect(); + browser.test.notifyPass("filter_end"); + }; + filter.onerror = () => { + browser.test.fail(`Unexpected error: ${filter.error}`); + browser.test.notifyFail("filter_end"); + }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq(`${BASE_URL}/redir`, details.url, "Got redirect"); + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstop = () => { + filter.disconnect(); + browser.test.fail("Unexpected onstop for redirect"); + browser.test.sendMessage("redirect_done"); + }; + filter.onerror = () => { + browser.test.assertEq( + // TODO bug 1683862: must be "Channel redirected", but it is not + // because document requests are handled differently compared to + // other requests, see the comment at the top of + // test_ext_webRequest_redirect_StreamFilter.js. + "Invalid request ID", + filter.error, + "Expected error in filter.onerror" + ); + browser.test.sendMessage("redirect_done"); + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background: `(${background})(${JSON.stringify(BASE_URL)})`, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(viewSourceUrl); + if (viewSourceUrl.includes("/redir")) { + info("Awaiting observed completion of redirection request"); + await extension.awaitMessage("redirect_done"); + } + info("Awaiting completion of StreamFilter on request"); + await extension.awaitFinish("filter_end"); + let contentText = await contentPage.spawn(null, () => { + return this.content.document.body.textContent; + }); + equal(contentText, "PREFIX_ok_SUFFIX", "view-source response body"); + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_StreamFilter_viewsource() { + await testViewSource(`view-source:${BASE_URL}/dummy`); +}); + +add_task(async function test_StreamFilter_viewsource_redirect_target() { + await testViewSource(`view-source:${BASE_URL}/redir`); +}); + +// Sanity check: nothing bad happens if the underlying response is aborted. +add_task(async function test_StreamFilter_viewsource_cancel() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.disconnect(); + browser.test.fail("Unexpected filter.onstart"); + browser.test.notifyFail("filter_end"); + }; + filter.onerror = () => { + browser.test.assertEq("Invalid request ID", filter.error, "Error?"); + browser.test.notifyPass("filter_end"); + }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + browser.webRequest.onHeadersReceived.addListener( + () => { + browser.test.log("Intentionally canceling view-source request"); + return { cancel: true }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await extension.awaitFinish("filter_end"); + let contentText = await contentPage.spawn(null, () => { + return this.content.document.body.textContent; + }); + equal(contentText, "", "view-source request should have been canceled"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js new file mode 100644 index 0000000000..7e34d2b0b3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js @@ -0,0 +1,55 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_webSocket() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + "ws:", + new URL(details.url).protocol, + "ws protocol worked" + ); + browser.test.notifyPass("websocket"); + }, + { urls: ["ws://example.com/*"] }, + ["blocking"] + ); + + browser.test.onMessage.addListener(msg => { + let ws = new WebSocket("ws://example.com/dummy"); + ws.onopen = e => { + ws.send("data"); + }; + ws.onclose = e => {}; + ws.onerror = e => {}; + ws.onmessage = e => { + ws.close(); + }; + }); + browser.test.sendMessage("ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("go"); + await extension.awaitFinish("websocket"); + + // Wait until the next tick so that listener responses are processed + // before we unload. + await new Promise(executeSoon); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js new file mode 100644 index 0000000000..a1a387b5a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js @@ -0,0 +1,150 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let image = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" +); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)) + .buffer; + +async function testImageLoading(src, expectedAction) { + let imageLoadingPromise = new Promise((resolve, reject) => { + let cleanupListeners; + let testImage = document.createElement("img"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testImage.wrappedJSObject.setAttribute("src", src); + + let loadListener = () => { + cleanupListeners(); + resolve(expectedAction === "loaded"); + }; + + let errorListener = () => { + cleanupListeners(); + resolve(expectedAction === "blocked"); + }; + + cleanupListeners = () => { + testImage.removeEventListener("load", loadListener); + testImage.removeEventListener("error", errorListener); + }; + + testImage.addEventListener("load", loadListener); + testImage.addEventListener("error", errorListener); + + document.body.appendChild(testImage); + }); + + let success = await imageLoadingPromise; + browser.runtime.sendMessage({ + name: "image-loading", + expectedAction, + success, + }); +} + +add_task(async function test_web_accessible_resources_csp() { + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg.name === "image-loading") { + browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`); + browser.test.sendMessage(`image-${msg.expectedAction}`); + } else { + browser.test.sendMessage(msg); + } + }); + + browser.test.sendMessage("background-ready"); + } + + function content() { + window.addEventListener("message", function rcv(event) { + browser.runtime.sendMessage("script-ran"); + window.removeEventListener("message", rcv); + }); + + testImageLoading(browser.extension.getURL("image.png"), "loaded"); + + let testScriptElement = document.createElement("script"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testScriptElement.wrappedJSObject.setAttribute( + "src", + browser.extension.getURL("test_script.js") + ); + document.head.appendChild(testScriptElement); + browser.runtime.sendMessage("script-loaded"); + } + + function testScript() { + window.postMessage("test-script-loaded", "*"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*/file_csp.html"], + run_at: "document_end", + js: ["content_script_helper.js", "content_script.js"], + }, + ], + web_accessible_resources: ["image.png", "test_script.js"], + }, + background, + files: { + "content_script_helper.js": `${testImageLoading}`, + "content_script.js": content, + "test_script.js": testScript, + "image.png": IMAGE_ARRAYBUFFER, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("background-ready"), + ]); + + let page = await ExtensionTestUtils.loadContentPage( + `http://example.com/data/file_sample.html` + ); + await page.spawn(null, () => { + let { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + this.obs = { + events: [], + observe(subject, topic, data) { + this.events.push(subject.QueryInterface(Ci.nsIURI).spec); + }, + done() { + Services.obs.removeObserver(this, "csp-on-violate-policy"); + return this.events; + }, + }; + Services.obs.addObserver(this.obs, "csp-on-violate-policy"); + content.location.href = "http://example.com/data/file_csp.html"; + }); + + await Promise.all([ + extension.awaitMessage("image-loaded"), + extension.awaitMessage("script-loaded"), + extension.awaitMessage("script-ran"), + ]); + + let events = await page.spawn(null, () => this.obs.done()); + equal(events.length, 2, "Two items were rejected by CSP"); + for (let url of events) { + ok( + url.includes("file_image_bad.png") || url.includes("file_script_bad.js"), + `Expected file: ${url} rejected by CSP` + ); + } + + await page.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js new file mode 100644 index 0000000000..640e5be0de --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js @@ -0,0 +1,72 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_xhr_capabilities() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let xhr = new XMLHttpRequest(); + xhr.open("GET", browser.extension.getURL("bad.xml")); + + browser.test.sendMessage("result", { + name: "Background script XHRs should not be privileged", + result: xhr.channel === undefined, + }); + + xhr.onload = () => { + browser.test.sendMessage("result", { + name: "Background script XHRs should not yield <parsererrors>", + result: xhr.responseXML === null, + }); + }; + xhr.send(); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + web_accessible_resources: ["bad.xml"], + }, + + files: { + "bad.xml": "<xml", + "content_script.js"() { + let xhr = new XMLHttpRequest(); + xhr.open("GET", browser.extension.getURL("bad.xml")); + + browser.test.sendMessage("result", { + name: "Content script XHRs should not be privileged", + result: xhr.channel === undefined, + }); + + xhr.onload = () => { + browser.test.sendMessage("result", { + name: "Content script XHRs should not yield <parsererrors>", + result: xhr.responseXML === null, + }); + }; + xhr.send(); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + // We expect four test results from the content/background scripts. + for (let i = 0; i < 4; ++i) { + let result = await extension.awaitMessage("result"); + ok(result.result, result.name); + } + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js new file mode 100644 index 0000000000..9e168107ff --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js @@ -0,0 +1,99 @@ +"use strict"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, because this + // test does not make sense with the legacy method (which will be removed in + // the above bug). + await ExtensionPermissions._uninit(); +}); + +const GOOD_JSON_FILE = { + "wikipedia@search.mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, + "amazon@search.mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, + "doh-rollout@mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, +}; + +const BAD_JSON_FILE = { + "test@example.org": "what", +}; + +const BAD_FILE = "what is this { } {"; + +const gOldSettingsJSON = do_get_profile().clone(); +gOldSettingsJSON.append("extension-preferences.json"); + +async function test_file(json, extensionIds, expected, fileDeleted) { + await ExtensionPermissions._resetVersion(); + await ExtensionPermissions._uninit(); + + await OS.File.writeAtomic(gOldSettingsJSON.path, json, { + encoding: "utf-8", + }); + + for (let extensionId of extensionIds) { + let permissions = await ExtensionPermissions.get(extensionId); + Assert.deepEqual(permissions, expected, "permissions match"); + } + + Assert.equal( + await OS.File.exists(gOldSettingsJSON.path), + !fileDeleted, + "old file was deleted" + ); +} + +add_task(async function test_migrate_good_json() { + let expected = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }; + + await test_file( + JSON.stringify(GOOD_JSON_FILE), + [ + "wikipedia@search.mozilla.org", + "amazon@search.mozilla.org", + "doh-rollout@mozilla.org", + ], + expected, + /* fileDeleted */ true + ); +}); + +add_task(async function test_migrate_bad_json() { + let expected = { permissions: [], origins: [] }; + + await test_file( + BAD_FILE, + ["test@example.org"], + expected, + /* fileDeleted */ false + ); + await OS.File.remove(gOldSettingsJSON.path); +}); + +add_task(async function test_migrate_bad_file() { + let expected = { permissions: [], origins: [] }; + + await test_file( + JSON.stringify(BAD_JSON_FILE), + ["test2@example.org"], + expected, + /* fileDeleted */ false + ); + await OS.File.remove(gOldSettingsJSON.path); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js new file mode 100644 index 0000000000..54a24233e2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js @@ -0,0 +1,172 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; + +const CATEGORY_EXTENSION_MODULES = "webextension-modules"; +const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas"; +const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts"; + +const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon"; +const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content"; +const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools"; + +let schemaURLs = new Set(); +schemaURLs.add("chrome://extensions/content/schemas/experiments.json"); + +// Helper class used to load the API modules similarly to the apiManager +// defined in ExtensionParent.jsm. +class FakeAPIManager extends ExtensionCommon.SchemaAPIManager { + constructor(processType = "main") { + super(processType, Schemas); + this.initialized = false; + } + + getModuleJSONURLs() { + return Array.from( + Services.catMan.enumerateCategory(CATEGORY_EXTENSION_MODULES), + ({ value }) => value + ); + } + + async lazyInit() { + if (this.initialized) { + return; + } + + this.initialized = true; + + let modulesPromise = this.loadModuleJSON(this.getModuleJSONURLs()); + + let scriptURLs = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS + )) { + scriptURLs.push(value); + } + + let scripts = await Promise.all( + scriptURLs.map(url => ChromeUtils.compileScript(url)) + ); + + this.initModuleData(await modulesPromise); + + this.initGlobal(); + for (let script of scripts) { + script.executeInGlobal(this.global); + } + + // Load order matters here. The base manifest defines types which are + // extended by other schemas, so needs to be loaded first. + await Schemas.load(BASE_SCHEMA).then(() => { + let promises = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCHEMAS + )) { + promises.push(Schemas.load(value)); + } + for (let [url, { content }] of this.schemaURLs) { + promises.push(Schemas.load(url, content)); + } + for (let url of schemaURLs) { + promises.push(Schemas.load(url)); + } + return Promise.all(promises).then(() => { + Schemas.updateSharedSchemas(); + }); + }); + } + + async loadAllModules(reverseOrder = false) { + await this.lazyInit(); + + let apiModuleNames = Array.from(this.modules.keys()) + .filter(moduleName => { + let moduleDesc = this.modules.get(moduleName); + return moduleDesc && !!moduleDesc.url; + }) + .sort(); + + apiModuleNames = reverseOrder ? apiModuleNames.reverse() : apiModuleNames; + + for (let apiModule of apiModuleNames) { + info( + `Loading apiModule ${apiModule}: ${this.modules.get(apiModule).url}` + ); + await this.asyncLoadModule(apiModule); + } + } +} + +// Specialized helper class used to test loading "child process" modules (similarly to the +// SchemaAPIManagers sub-classes defined in ExtensionPageChild.jsm and ExtensionContent.jsm). +class FakeChildProcessAPIManager extends FakeAPIManager { + constructor({ processType, categoryScripts }) { + super(processType, Schemas); + + this.categoryScripts = categoryScripts; + } + + async lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + this.categoryScripts + )) { + await this.loadScript(value); + } + } + } +} + +async function test_loading_api_modules(createAPIManager) { + let fakeAPIManager; + + info("Load API modules in alphabetic order"); + + fakeAPIManager = createAPIManager(); + await fakeAPIManager.loadAllModules(); + + info("Load API modules in reverse order"); + + fakeAPIManager = createAPIManager(); + await fakeAPIManager.loadAllModules(true); +} + +add_task(function test_loading_main_process_api_modules() { + return test_loading_api_modules(() => { + return new FakeAPIManager(); + }); +}); + +add_task(function test_loading_extension_process_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "addon", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_ADDON, + }); + }); +}); + +add_task(function test_loading_devtools_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "devtools", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS, + }); + }); +}); + +add_task(async function test_loading_content_process_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "content", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_CONTENT, + }); + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_converter.js b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js new file mode 100644 index 0000000000..6729639cc9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js @@ -0,0 +1,146 @@ +"use strict"; + +const convService = Cc["@mozilla.org/streamConverters;1"].getService( + Ci.nsIStreamConverterService +); + +const UUID = "72b61ee3-aceb-476c-be1b-0822b036c9f1"; +const ADDON_ID = "test@web.extension"; +const URI = NetUtil.newURI(`moz-extension://${UUID}/file.css`); + +const FROM_TYPE = "application/vnd.mozilla.webext.unlocalized"; +const TO_TYPE = "text/css"; + +function StringStream(string) { + let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + + stream.data = string; + return stream; +} + +// Initialize the policy service with a stub localizer for our +// add-on ID. +add_task(async function init() { + let policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: UUID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + + localizeCallback(string) { + return string.replace(/__MSG_(.*?)__/g, "<localized-$1>"); + }, + }); + + policy.active = true; + + registerCleanupFunction(() => { + policy.active = false; + }); +}); + +// Test that the synchronous converter works as expected with a +// simple string. +add_task(async function testSynchronousConvert() { + let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz"); + + let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI); + + let result = NetUtil.readInputStreamToString( + resultStream, + resultStream.available() + ); + + equal(result, "Foo <localized-xxx> bar <localized-yyy> baz"); +}); + +// Test that the asynchronous converter works as expected with input +// split into multiple chunks, and a boundary in the middle of a +// replacement token. +add_task(async function testAsyncConvert() { + let listener; + let awaitResult = new Promise((resolve, reject) => { + listener = { + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onDataAvailable(request, inputStream, offset, count) { + this.resultParts.push( + NetUtil.readInputStreamToString(inputStream, count) + ); + }, + + onStartRequest() { + ok(!("resultParts" in this)); + this.resultParts = []; + }, + + onStopRequest(request, context, statusCode) { + if (!Components.isSuccessCode(statusCode)) { + reject(new Error(statusCode)); + } + + resolve(this.resultParts.join("\n")); + }, + }; + }); + + let parts = ["Foo __MSG_x", "xx__ bar __MSG_yyy__ baz"]; + + let converter = convService.asyncConvertData( + FROM_TYPE, + TO_TYPE, + listener, + URI + ); + converter.onStartRequest(null, null); + + for (let part of parts) { + converter.onDataAvailable(null, StringStream(part), 0, part.length); + } + + converter.onStopRequest(null, null, Cr.NS_OK); + + let result = await awaitResult; + equal(result, "Foo <localized-xxx> bar <localized-yyy> baz"); +}); + +// Test that attempting to initialize a converter with the URI of a +// nonexistent WebExtension fails. +add_task(async function testInvalidUUID() { + let uri = NetUtil.newURI( + "moz-extension://eb4f3be8-41c9-4970-aa6d-b84d1ecc02b2/file.css" + ); + let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz"); + + // Assert.throws raise a TypeError exception when the expected param + // is an arrow function. (See Bug 1237961 for rationale) + let expectInvalidContextException = function(e) { + return e.result === Cr.NS_ERROR_INVALID_ARG && /Invalid context/.test(e); + }; + + Assert.throws(() => { + convService.convert(stream, FROM_TYPE, TO_TYPE, uri); + }, expectInvalidContextException); + + Assert.throws(() => { + let listener = { + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + }; + + convService.asyncConvertData(FROM_TYPE, TO_TYPE, listener, uri); + }, expectInvalidContextException); +}); + +// Test that an empty stream does not throw an NS_ERROR_ILLEGAL_VALUE. +add_task(async function testEmptyStream() { + let stream = StringStream(""); + let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI); + equal( + resultStream.available(), + 0, + "Size of output stream should match size of input stream" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_data.js b/toolkit/components/extensions/test/xpcshell/test_locale_data.js new file mode 100644 index 0000000000..32155a7c91 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_locale_data.js @@ -0,0 +1,221 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { ExtensionData } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" +); + +async function generateAddon(data) { + let xpi = AddonTestUtils.createTempWebExtensionFile(data); + + let fileURI = Services.io.newFileURI(xpi); + let jarURI = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(jarURI); + await extension.loadManifest(); + + return extension; +} + +add_task(async function testMissingDefaultLocale() { + let extension = await generateAddon({ + files: { + "_locales/en_US/messages.json": {}, + }, + }); + + equal(extension.errors.length, 0, "No errors reported"); + + await extension.initAllLocales(); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes('"default_locale" property is required'), + "Got missing default_locale error" + ); +}); + +add_task(async function testInvalidDefaultLocale() { + let extension = await generateAddon({ + manifest: { + default_locale: "en", + }, + + files: { + "_locales/en_US/messages.json": {}, + }, + }); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en/messages.json" + ), + "Got invalid default_locale error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "Two errors reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes('"default_locale" property must correspond'), + "Got invalid default_locale error" + ); +}); + +add_task(async function testUnexpectedDefaultLocale() { + let extension = await generateAddon({ + manifest: { + default_locale: "en_US", + }, + }); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en-US/messages.json" + ), + "Got invalid default_locale error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "One error reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes('"default_locale" property must correspond'), + "Got unexpected default_locale error" + ); +}); + +add_task(async function testInvalidSyntax() { + let extension = await generateAddon({ + manifest: { + default_locale: "en_US", + }, + + files: { + "_locales/en_US/messages.json": + '{foo: {message: "bar", description: "baz"}}', + }, + }); + + equal(extension.errors.length, 1, "No errors reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en_US/messages.json: SyntaxError" + ), + "Got syntax error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "One error reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes( + "Loading locale file _locales/en_US/messages.json: SyntaxError" + ), + "Got syntax error" + ); +}); + +add_task(async function testExtractLocalizedManifest() { + let extension = await generateAddon({ + manifest: { + name: "__MSG_extensionName__", + default_locale: "en_US", + icons: { + "16": "__MSG_extensionIcon__", + }, + }, + + files: { + "_locales/en_US/messages.json": `{ + "extensionName": {"message": "foo"}, + "extensionIcon": {"message": "icon-en.png"} + }`, + "_locales/de_DE/messages.json": `{ + "extensionName": {"message": "bar"}, + "extensionIcon": {"message": "icon-de.png"} + }`, + }, + }); + + await extension.loadManifest(); + equal(extension.manifest.name, "foo", "name localized"); + equal(extension.manifest.icons["16"], "icon-en.png", "icons localized"); + + let manifest = await extension.getLocalizedManifest("de-DE"); + ok(extension.localeData.has("de-DE"), "has de_DE locale"); + equal(manifest.name, "bar", "name localized"); + equal(manifest.icons["16"], "icon-de.png", "icons localized"); + + await Assert.rejects( + extension.getLocalizedManifest("xx-XX"), + /does not contain the locale xx-XX/, + "xx-XX does not exist" + ); +}); + +add_task(async function testRestartThenExtractLocalizedManifest() { + await AddonTestUtils.promiseStartupManager(); + + let wrapper = ExtensionTestUtils.loadExtension({ + manifest: { + name: "__MSG_extensionName__", + default_locale: "en_US", + }, + useAddonManager: "permanent", + files: { + "_locales/en_US/messages.json": '{"extensionName": {"message": "foo"}}', + "_locales/de_DE/messages.json": '{"extensionName": {"message": "bar"}}', + }, + }); + + await wrapper.startup(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + let { extension } = wrapper; + let manifest = await extension.getLocalizedManifest("de-DE"); + ok(extension.localeData.has("de-DE"), "has de_DE locale"); + equal(manifest.name, "bar", "name localized"); + + await Assert.rejects( + extension.getLocalizedManifest("xx-XX"), + /does not contain the locale xx-XX/, + "xx-XX does not exist" + ); + + await wrapper.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_native_manifests.js b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js new file mode 100644 index 0000000000..ca32517fd5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js @@ -0,0 +1,443 @@ +"use strict"; + +const { AsyncShutdown } = ChromeUtils.import( + "resource://gre/modules/AsyncShutdown.jsm" +); +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { NativeManifests } = ChromeUtils.import( + "resource://gre/modules/NativeManifests.jsm" +); +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); +const { Subprocess, SubprocessImpl } = ChromeUtils.import( + "resource://gre/modules/Subprocess.jsm", + null +); +const { NativeApp } = ChromeUtils.import( + "resource://gre/modules/NativeMessaging.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +let registry = null; +if (AppConstants.platform == "win") { + var { MockRegistry } = ChromeUtils.import( + "resource://testing-common/MockRegistry.jsm" + ); + registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); +} + +const REGPATH = "Software\\Mozilla\\NativeMessagingHosts"; + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; + +const TYPE_SLUG = + AppConstants.platform === "linux" + ? "native-messaging-hosts" + : "NativeMessagingHosts"; + +let dir = FileUtils.getDir("TmpD", ["NativeManifests"]); +dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let userDir = dir.clone(); +userDir.append("user"); +userDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let globalDir = dir.clone(); +globalDir.append("global"); +globalDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +OS.File.makeDir(OS.Path.join(userDir.path, TYPE_SLUG)); +OS.File.makeDir(OS.Path.join(globalDir.path, TYPE_SLUG)); + +let dirProvider = { + getFile(property) { + if (property == "XREUserNativeManifests") { + return userDir.clone(); + } else if (property == "XRESysNativeManifests") { + return globalDir.clone(); + } + return null; + }, +}; + +Services.dirsvc.registerProvider(dirProvider); + +registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + dir.remove(true); +}); + +function writeManifest(path, manifest) { + if (typeof manifest != "string") { + manifest = JSON.stringify(manifest); + } + return OS.File.writeAtomic(path, manifest); +} + +let PYTHON; +add_task(async function setup() { + await Schemas.load(BASE_SCHEMA); + + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + try { + PYTHON = await Subprocess.pathSearch(env.get("PYTHON")); + } catch (e) { + notEqual( + PYTHON, + null, + `Can't find a suitable python interpreter ${e.message}` + ); + } +}); + +let global = this; + +// Test of NativeManifests.lookupApplication() begin here... +let context = { + extension: { + id: "extension@tests.mozilla.org", + }, + envType: "addon_parent", + url: null, + jsonStringify(...args) { + return JSON.stringify(...args); + }, + cloneScope: global, + logError() {}, + preprocessors: {}, + callOnClose: () => {}, + forgetOnClose: () => {}, +}; + +class MockContext extends ExtensionCommon.BaseContext { + constructor(extensionId) { + let fakeExtension = { id: extensionId }; + super("addon_parent", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return global; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +let templateManifest = { + name: "test", + description: "this is only a test", + path: "/bin/cat", + type: "stdio", + allowed_extensions: ["extension@tests.mozilla.org"], +}; + +function lookupApplication(app, ctx) { + return NativeManifests.lookupManifest("stdio", app, ctx); +} + +add_task(async function test_nonexistent_manifest() { + let result = await lookupApplication("test", context); + equal( + result, + null, + "lookupApplication returns null for non-existent application" + ); +}); + +const USER_TEST_JSON = OS.Path.join(userDir.path, TYPE_SLUG, "test.json"); + +add_task(async function test_nonexistent_manifest_with_registry_entry() { + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + + await OS.File.remove(USER_TEST_JSON); + let { messages, result } = await promiseConsoleOutput(() => + lookupApplication("test", context) + ); + equal( + result, + null, + "lookupApplication returns null for non-existent manifest" + ); + + let noSuchFileErrors = messages.filter(logMessage => + logMessage.message.includes( + "file is referenced in the registry but does not exist" + ) + ); + + if (registry) { + equal( + noSuchFileErrors.length, + 1, + "lookupApplication logs a non-existent manifest file pointed to by the registry" + ); + } else { + equal( + noSuchFileErrors.length, + 0, + "lookupApplication does not log about registry on non-windows platforms" + ); + } +}); + +add_task(async function test_good_manifest() { + await writeManifest(USER_TEST_JSON, templateManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + + let result = await lookupApplication("test", context); + notEqual(result, null, "lookupApplication finds a good manifest"); + equal( + result.path, + USER_TEST_JSON, + "lookupApplication returns the correct path" + ); + deepEqual( + result.manifest, + templateManifest, + "lookupApplication returns the manifest contents" + ); +}); + +add_task(async function test_invalid_json() { + await writeManifest(USER_TEST_JSON, "this is not valid json"); + let result = await lookupApplication("test", context); + equal(result, null, "lookupApplication ignores bad json"); +}); + +add_task(async function test_invalid_name() { + let manifest = Object.assign({}, templateManifest); + manifest.name = "../test"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, "lookupApplication ignores an invalid name"); +}); + +add_task(async function test_name_mismatch() { + let manifest = Object.assign({}, templateManifest); + manifest.name = "not test"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + let what = AppConstants.platform == "win" ? "registry key" : "json filename"; + equal( + result, + null, + `lookupApplication ignores mistmatch between ${what} and name property` + ); +}); + +add_task(async function test_missing_props() { + const PROPS = ["name", "description", "path", "type", "allowed_extensions"]; + for (let prop of PROPS) { + let manifest = Object.assign({}, templateManifest); + delete manifest[prop]; + + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, `lookupApplication ignores missing ${prop}`); + } +}); + +add_task(async function test_invalid_type() { + let manifest = Object.assign({}, templateManifest); + manifest.type = "bogus"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, "lookupApplication ignores invalid type"); +}); + +add_task(async function test_no_allowed_extensions() { + let manifest = Object.assign({}, templateManifest); + manifest.allowed_extensions = []; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal( + result, + null, + "lookupApplication ignores manifest with no allowed_extensions" + ); +}); + +const GLOBAL_TEST_JSON = OS.Path.join(globalDir.path, TYPE_SLUG, "test.json"); +let globalManifest = Object.assign({}, templateManifest); +globalManifest.description = "This manifest is from the systemwide directory"; + +add_task(async function good_manifest_system_dir() { + await OS.File.remove(USER_TEST_JSON); + await writeManifest(GLOBAL_TEST_JSON, globalManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + null + ); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + `${REGPATH}\\test`, + "", + GLOBAL_TEST_JSON + ); + } + + let where = + AppConstants.platform == "win" ? "registry location" : "directory"; + let result = await lookupApplication("test", context); + notEqual( + result, + null, + `lookupApplication finds a manifest in the system-wide ${where}` + ); + equal( + result.path, + GLOBAL_TEST_JSON, + `lookupApplication returns path in the system-wide ${where}` + ); + deepEqual( + result.manifest, + globalManifest, + `lookupApplication returns manifest contents from the system-wide ${where}` + ); +}); + +add_task(async function test_user_dir_precedence() { + await writeManifest(USER_TEST_JSON, templateManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + // global test.json and LOCAL_MACHINE registry key on windows are + // still present from the previous test + + let result = await lookupApplication("test", context); + notEqual( + result, + null, + "lookupApplication finds a manifest when entries exist in both user-specific and system-wide locations" + ); + equal( + result.path, + USER_TEST_JSON, + "lookupApplication returns the user-specific path when user-specific and system-wide entries both exist" + ); + deepEqual( + result.manifest, + templateManifest, + "lookupApplication returns user-specific manifest contents with user-specific and system-wide entries both exist" + ); +}); + +// Test shutdown handling in NativeApp +add_task(async function test_native_app_shutdown() { + const SCRIPT = String.raw` +import signal +import struct +import sys + +signal.signal(signal.SIGTERM, signal.SIG_IGN) + +stdin = getattr(sys.stdin, 'buffer', sys.stdin) +stdout = getattr(sys.stdout, 'buffer', sys.stdout) + +while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + signal.pause() + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + + let scriptPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.py"); + let manifestPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.json"); + + const ID = "native@tests.mozilla.org"; + let manifest = { + name: "wontdie", + description: "test async shutdown of native apps", + type: "stdio", + allowed_extensions: [ID], + }; + + if (AppConstants.platform == "win") { + await OS.File.writeAtomic(scriptPath, SCRIPT); + + let batPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.bat"); + let batBody = `@ECHO OFF\n${PYTHON} -u "${scriptPath}" %*\n`; + await OS.File.writeAtomic(batPath, batBody); + await OS.File.setPermissions(batPath, { unixMode: 0o755 }); + + manifest.path = batPath; + await writeManifest(manifestPath, manifest); + + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\wontdie`, + "", + manifestPath + ); + } else { + await OS.File.writeAtomic(scriptPath, `#!${PYTHON} -u\n${SCRIPT}`); + await OS.File.setPermissions(scriptPath, { unixMode: 0o755 }); + manifest.path = scriptPath; + await writeManifest(manifestPath, manifest); + } + + let mockContext = new MockContext(ID); + let app = new NativeApp(mockContext, "wontdie"); + + // send a message and wait for the reply to make sure the app is running + let MSG = "test"; + let recvPromise = new Promise(resolve => { + let listener = (what, msg) => { + equal(msg, MSG, "Received test message"); + app.off("message", listener); + resolve(); + }; + app.on("message", listener); + }); + + let buffer = NativeApp.encodeMessage(mockContext, MSG); + app.send(new StructuredCloneHolder(buffer)); + await recvPromise; + + app._cleanup(); + + info("waiting for async shutdown"); + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.profileBeforeChange._trigger(); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + + let procs = await SubprocessImpl.Process.getWorker().call("getProcesses", []); + equal(procs.size, 0, "native process exited"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js new file mode 100644 index 0000000000..0763c60abe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js @@ -0,0 +1,103 @@ +"use strict"; + +/* eslint no-unused-vars: ["error", {"args": "none", "varsIgnorePattern": "^(FindProxyForURL)$"}] */ + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_incognito_proxy_onRequest_access() { + // No specific support exists in the proxy api for this test, + // rather it depends on functionality existing in ChannelWrapper + // that prevents notification of private channels if the + // extension does not have permission. + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + // This extension will fail if it gets a private request. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + async background() { + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("proxy.onRequest"); + }, + { urls: ["<all_urls>"], types: ["main_frame"] } + ); + + // Actual call arguments do not matter here. + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "none", + }, + }), + /proxy.settings requires private browsing permission/, + "proxy.settings requires private browsing permission." + ); + + browser.test.sendMessage("ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("ready"); + + let pextension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertTrue( + details.incognito, + "incognito flag is set with filter" + ); + browser.test.sendMessage("proxy.onRequest.private"); + }, + { urls: ["<all_urls>"], types: ["main_frame"], incognito: true } + ); + + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set with filter" + ); + browser.test.notifyPass("proxy.onRequest.spanning"); + }, + { urls: ["<all_urls>"], types: ["main_frame"], incognito: false } + ); + }, + }); + await pextension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + await pextension.awaitMessage("proxy.onRequest.private"); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitFinish("proxy.onRequest"); + await pextension.awaitFinish("proxy.onRequest.spanning"); + await contentPage.close(); + + await pextension.unload(); + await extension.unload(); + + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js new file mode 100644 index 0000000000..c222642d52 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js @@ -0,0 +1,469 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService" +); + +const TRANSPARENT_PROXY_RESOLVES_HOST = + Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + +let extension; +add_task(async function setup() { + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + let settings = { proxy: null }; + + browser.proxy.onError.addListener(error => { + browser.test.log(`error received ${error.message}`); + browser.test.sendMessage("proxy-error-received", error); + }); + browser.test.onMessage.addListener((message, data) => { + if (message === "set-proxy") { + settings.proxy = data.proxy; + browser.test.sendMessage("proxy-set", settings.proxy); + } + }); + browser.proxy.onRequest.addListener( + () => { + return settings.proxy; + }, + { urls: ["<all_urls>"] } + ); + }, + }; + extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); +}); + +async function setupProxyResult(proxy) { + extension.sendMessage("set-proxy", { proxy }); + let proxyInfoSent = await extension.awaitMessage("proxy-set"); + deepEqual( + proxyInfoSent, + proxy, + "got back proxy data from the proxy listener" + ); +} + +async function testProxyResolution(test) { + let { uri, proxy, expected } = test; + let errorMsg; + if (expected.error) { + errorMsg = extension.awaitMessage("proxy-error-received"); + } + let proxyInfo = await new Promise((resolve, reject) => { + let channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + + gProxyService.asyncResolve(channel, 0, { + onProxyAvailable(req, uri, pi, status) { + resolve(pi && pi.QueryInterface(Ci.nsIProxyInfo)); + }, + }); + }); + + let expectedProxyInfo = expected.proxyInfo; + if (expected.error) { + equal(proxyInfo, null, "Expected proxyInfo to be null"); + equal((await errorMsg).message, expected.error, "error received"); + } else if (proxy == null) { + equal(proxyInfo, expectedProxyInfo, "proxy is direct"); + } else { + for ( + let proxyUsed = proxyInfo; + proxyUsed; + proxyUsed = proxyUsed.failoverProxy + ) { + let { + type, + host, + port, + username, + password, + proxyDNS, + failoverTimeout, + } = expectedProxyInfo; + equal(proxyUsed.host, host, `Expected proxy host to be ${host}`); + equal(proxyUsed.port, port, `Expected proxy port to be ${port}`); + equal(proxyUsed.type, type, `Expected proxy type to be ${type}`); + // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo + equal( + proxyUsed.username || "", + username || "", + `Expected proxy username to be ${username}` + ); + equal( + proxyUsed.password || "", + password || "", + `Expected proxy password to be ${password}` + ); + equal( + proxyUsed.flags, + proxyDNS == undefined ? 0 : proxyDNS, + `Expected proxyDNS to be ${proxyDNS}` + ); + // Default timeout is 10 + equal( + proxyUsed.failoverTimeout, + failoverTimeout || 10, + `Expected failoverTimeout to be ${failoverTimeout}` + ); + expectedProxyInfo = expectedProxyInfo.failoverProxy; + } + } +} + +add_task(async function test_proxyInfo_results() { + let tests = [ + { + proxy: 5, + expected: { + error: "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + { + proxy: "INVALID", + expected: { + error: "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + { + proxy: { + type: "socks", + }, + expected: { + error: 'ProxyInfoData: Invalid proxy server host: "undefined"', + }, + }, + { + proxy: [ + { + type: "pptp", + host: "foo.bar", + port: 1080, + username: "mungosantamaria", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { + type: "http", + host: "192.168.1.1", + port: 1128, + username: "mungosantamaria", + password: "word321", + }, + ], + expected: { + error: 'ProxyInfoData: Invalid proxy server type: "pptp"', + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 65536, + username: "mungosantamaria", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { + type: "http", + host: "192.168.1.1", + port: 3128, + username: "mungosantamaria", + password: "word321", + }, + ], + expected: { + error: + "ProxyInfoData: Proxy server port 65536 outside range 1 to 65535", + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 3128, + proxyAuthorizationHeader: "test", + }, + ], + expected: { + error: 'ProxyInfoData: ProxyAuthorizationHeader requires type "https"', + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 3128, + connectionIsolationKey: 1234, + }, + ], + expected: { + error: 'ProxyInfoData: Invalid proxy connection isolation key: "1234"', + }, + }, + { + proxy: [{ type: "direct" }], + expected: { + proxyInfo: null, + }, + }, + { + proxy: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + }, + }, + { + uri: "ftp://mozilla.org", + proxy: { + host: "1.2.3.4", + port: "8180", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8180", + type: "http", + failoverProxy: null, + }, + }, + }, + { + proxy: { + host: "2.3.4.5", + port: "8181", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "2.3.4.5", + port: "8181", + type: "http", + failoverProxy: null, + }, + }, + }, + { + proxy: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: { + host: "4.4.4.4", + port: "9000", + type: "socks", + failoverProxy: { + type: "direct", + host: null, + port: -1, + }, + }, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: { + host: "4.4.4.4", + port: "9000", + type: "socks", + failoverProxy: { + type: "direct", + host: null, + port: -1, + }, + }, + }, + }, + }, + { + proxy: [{ type: "http", host: "foo.bar", port: 3128 }], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "http", + }, + }, + }, + { + proxy: { + host: "foo.bar", + port: "1080", + type: "socks", + }, + expected: { + proxyInfo: { + host: "foo.bar", + port: "1080", + type: "socks", + }, + }, + }, + { + proxy: { + host: "foo.bar", + port: "1080", + type: "socks4", + }, + expected: { + proxyInfo: { + host: "foo.bar", + port: "1080", + type: "socks4", + }, + }, + }, + { + proxy: [{ type: "https", host: "foo.bar", port: 3128 }], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "https", + }, + }, + }, + { + proxy: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "mungo", + password: "santamaria123", + proxyDNS: true, + failoverTimeout: 5, + }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + username: "mungo", + password: "santamaria123", + failoverTimeout: 5, + failoverProxy: null, + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + }, + }, + }, + { + proxy: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "johnsmith", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { type: "http", host: "192.168.1.1", port: 3128 }, + { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 }, + { + type: "socks", + host: "192.168.1.3", + port: 1999, + proxyDNS: true, + username: "mungosantamaria", + password: "foobar", + }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + proxyDNS: true, + username: "johnsmith", + password: "pass123", + failoverTimeout: 3, + failoverProxy: { + host: "192.168.1.1", + port: 3128, + type: "http", + failoverProxy: { + host: "192.168.1.2", + port: 1121, + type: "https", + failoverTimeout: 1, + failoverProxy: { + host: "192.168.1.3", + port: 1999, + type: "socks", + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + username: "mungosantamaria", + password: "foobar", + failoverProxy: { + type: "direct", + }, + }, + }, + }, + }, + }, + }, + { + proxy: [ + { + type: "https", + host: "foo.bar", + port: 3128, + proxyAuthorizationHeader: "test", + connectionIsolationKey: "key", + }, + ], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "https", + proxyAuthorizationHeader: "test", + connectionIsolationKey: "key", + }, + }, + }, + ]; + for (let test of tests) { + await setupProxyResult(test.proxy); + if (!test.uri) { + test.uri = "http://www.mozilla.org/"; + } + await testProxyResolution(test); + } +}); + +add_task(async function shutdown() { + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js new file mode 100644 index 0000000000..5dc099baf6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js @@ -0,0 +1,318 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService" +); + +const TRANSPARENT_PROXY_RESOLVES_HOST = + Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + +function getProxyInfo(url = "http://www.mozilla.org/") { + return new Promise((resolve, reject) => { + let channel = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + }); + + gProxyService.asyncResolve(channel, 0, { + onProxyAvailable(req, uri, pi, status) { + resolve(pi); + }, + }); + }); +} + +const testData = [ + { + // An ExtensionError is thrown for this, but we are unable to catch it as we + // do with the PAC script api. In this case, we expect null for proxyInfo. + proxyInfo: "not_defined", + expected: { + proxyInfo: null, + }, + }, + { + proxyInfo: 1, + expected: { + error: { + message: + "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + }, + { + proxyInfo: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "johnsmith", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { type: "http", host: "192.168.1.1", port: 3128 }, + { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 }, + { + type: "socks", + host: "192.168.1.3", + port: 1999, + proxyDNS: true, + username: "mungosantamaria", + password: "foobar", + }, + { type: "direct" }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + proxyDNS: true, + username: "johnsmith", + password: "pass123", + failoverTimeout: 3, + failoverProxy: { + host: "192.168.1.1", + port: 3128, + type: "http", + failoverProxy: { + host: "192.168.1.2", + port: 1121, + type: "https", + failoverTimeout: 1, + failoverProxy: { + host: "192.168.1.3", + port: 1999, + type: "socks", + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + username: "mungosantamaria", + password: "foobar", + failoverProxy: { + type: "direct", + }, + }, + }, + }, + }, + }, + }, +]; + +add_task(async function test_proxy_listener() { + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + // Some tests generate multiple errors, we'll just rely on the first. + let seenError = false; + let proxyInfo; + browser.proxy.onError.addListener(error => { + if (!seenError) { + browser.test.sendMessage("proxy-error-received", error); + seenError = true; + } + }); + + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + if (proxyInfo == "not_defined") { + return not_defined; // eslint-disable-line no-undef + } + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + + browser.test.onMessage.addListener((message, data) => { + if (message === "set-proxy") { + seenError = false; + proxyInfo = data.proxyInfo; + } + }); + + browser.test.sendMessage("ready"); + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let test of testData) { + extension.sendMessage("set-proxy", test); + let testError = test.expected.error; + let errorWait = testError && extension.awaitMessage("proxy-error-received"); + + let proxyInfo = await getProxyInfo(); + let expectedProxyInfo = test.expected.proxyInfo; + + if (testError) { + info("waiting for error data"); + let error = await errorWait; + equal(error.message, testError.message, "Correct error message received"); + equal(proxyInfo, null, "no proxyInfo received"); + } else if (expectedProxyInfo === null) { + equal(proxyInfo, null, "no proxyInfo received"); + } else { + for ( + let proxyUsed = proxyInfo; + proxyUsed; + proxyUsed = proxyUsed.failoverProxy + ) { + let { + type, + host, + port, + username, + password, + proxyDNS, + failoverTimeout, + } = expectedProxyInfo; + equal(proxyUsed.host, host, `Expected proxy host to be ${host}`); + equal(proxyUsed.port, port || -1, `Expected proxy port to be ${port}`); + equal(proxyUsed.type, type, `Expected proxy type to be ${type}`); + // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo + equal( + proxyUsed.username || "", + username || "", + `Expected proxy username to be ${username}` + ); + equal( + proxyUsed.password || "", + password || "", + `Expected proxy password to be ${password}` + ); + equal( + proxyUsed.flags, + proxyDNS == undefined ? 0 : proxyDNS, + `Expected proxyDNS to be ${proxyDNS}` + ); + // Default timeout is 10 + equal( + proxyUsed.failoverTimeout, + failoverTimeout || 10, + `Expected failoverTimeout to be ${failoverTimeout}` + ); + expectedProxyInfo = expectedProxyInfo.failoverProxy; + } + ok(!expectedProxyInfo, "no left over failoverProxy"); + } + } + + await extension.unload(); +}); + +async function getExtension(expectedProxyInfo) { + function background(proxyInfo) { + browser.test.log( + `testing proxy.onRequest with proxyInfo = ${JSON.stringify(proxyInfo)}` + ); + browser.proxy.onRequest.addListener( + details => { + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + } + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})(${JSON.stringify(expectedProxyInfo)})`, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + return extension; +} + +add_task(async function test_passthrough() { + let ext1 = await getExtension(null); + let ext2 = await getExtension({ host: "1.2.3.4", port: 8888, type: "https" }); + + // Also use a restricted url to test the ability to proxy those. + let proxyInfo = await getProxyInfo("https://addons.mozilla.org/"); + + equal(proxyInfo.host, "1.2.3.4", `second extension won`); + equal(proxyInfo.port, "8888", `second extension won`); + equal(proxyInfo.type, "https", `second extension won`); + + await ext2.unload(); + + proxyInfo = await getProxyInfo(); + equal(proxyInfo, null, `expected no proxy`); + await ext1.unload(); +}); + +add_task(async function test_ftp() { + Services.prefs.setBoolPref("network.ftp.enabled", true); + let extension = await getExtension({ + host: "1.2.3.4", + port: 8888, + type: "http", + }); + + let proxyInfo = await getProxyInfo("ftp://somewhere.mozilla.org/"); + + equal(proxyInfo.host, "1.2.3.4", `proxy host correct`); + equal(proxyInfo.port, "8888", `proxy port correct`); + equal(proxyInfo.type, "http", `proxy type correct`); + + await extension.unload(); + Services.prefs.clearUserPref("network.ftp.enabled"); +}); + +add_task(async function test_ftp_disabled() { + Services.prefs.setBoolPref("network.ftp.enabled", false); + let extension = await getExtension({ + host: "1.2.3.4", + port: 8888, + type: "http", + }); + + let proxyInfo = await getProxyInfo("ftp://somewhere.mozilla.org/"); + + equal( + proxyInfo, + null, + `proxy of ftp request is not available when ftp is disabled` + ); + + await extension.unload(); + Services.prefs.clearUserPref("network.ftp.enabled"); +}); + +add_task(async function test_ws() { + let proxyRequestCount = 0; + let proxy = createHttpServer(); + proxy.registerPathHandler("CONNECT", (request, response) => { + response.setStatusLine(request.httpVersion, 404, "Proxy not found"); + ++proxyRequestCount; + }); + + let extension = await getExtension({ + host: proxy.identity.primaryHost, + port: proxy.identity.primaryPort, + type: "http", + }); + + // We need a page to use the WebSocket constructor, so let's use an extension. + let dummy = ExtensionTestUtils.loadExtension({ + background() { + // The connection will not be upgraded to WebSocket, so it will close. + let ws = new WebSocket("wss://example.net/"); + ws.onclose = () => browser.test.sendMessage("websocket_closed"); + }, + }); + await dummy.startup(); + await dummy.awaitMessage("websocket_closed"); + await dummy.unload(); + + equal(proxyRequestCount, 1, "Expected one proxy request"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js new file mode 100644 index 0000000000..5dea560e02 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js @@ -0,0 +1,43 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_userContextId_proxy_onRequest() { + // This extension will succeed if it gets a request + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + browser.proxy.onRequest.addListener( + async details => { + if (details.url != "http://example.com/dummy") { + return; + } + browser.test.assertEq( + details.cookieStoreId, + "firefox-container-2", + "cookieStoreId is set" + ); + browser.test.notifyPass("proxy.onRequest"); + }, + { urls: ["<all_urls>"] } + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 2 } + ); + await extension.awaitFinish("proxy.onRequest"); + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js new file mode 100644 index 0000000000..7c083c7805 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js @@ -0,0 +1,79 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.import( + "resource://gre/modules/WebRequest.jsm" +); +var { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); +var { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function setup() { + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function test_ancestors_exist() { + let deferred = PromiseUtils.defer(); + function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + ok( + typeof details.frameAncestors === "object", + `ancestors exists [${typeof details.frameAncestors}]` + ); + deferred.resolve(); + } + + WebRequest.onBeforeRequest.addListener( + onBeforeRequest, + { urls: new MatchPatternSet(["http://example.com/*"]) }, + ["blocking"] + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await deferred.promise; + await contentPage.close(); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); +}); + +add_task(async function test_ancestors_null() { + let deferred = PromiseUtils.defer(); + function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + ok(details.frameAncestors === undefined, "ancestors do not exist"); + deferred.resolve(); + } + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, null, ["blocking"]); + + function fetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.mozBackgroundRequest = true; + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = () => { + reject(xhr.status); + }; + // use a different contextId to avoid auth cache. + xhr.setOriginAttributes({ userContextId: 1 }); + xhr.send(); + }); + } + + await fetch("http://example.com/data/file_sample.html"); + await deferred.promise; + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js new file mode 100644 index 0000000000..d13b2be40d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js @@ -0,0 +1,102 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.import( + "resource://gre/modules/WebRequest.jsm" +); + +var { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + if (request.hasHeader("Cookie")) { + let value = request.getHeader("Cookie"); + if (value == "blinky=1") { + response.setHeader("Set-Cookie", "dinky=1", false); + } + response.write("cookie-present"); + } else { + response.setHeader("Set-Cookie", "foopy=1", false); + response.write("cookie-not-present"); + } +}); + +const URL = "http://example.com/"; + +var countBefore = 0; +var countAfter = 0; + +function onBeforeSendHeaders(details) { + if (details.url != URL) { + return undefined; + } + + countBefore++; + + info(`onBeforeSendHeaders ${details.url}`); + let found = false; + let headers = []; + for (let { name, value } of details.requestHeaders) { + info(`Saw header ${name} '${value}'`); + if (name == "Cookie") { + equal(value, "foopy=1", "Cookie is correct"); + headers.push({ name, value: "blinky=1" }); + found = true; + } else { + headers.push({ name, value }); + } + } + ok(found, "Saw cookie header"); + equal(countBefore, 1, "onBeforeSendHeaders hit once"); + + return { requestHeaders: headers }; +} + +function onResponseStarted(details) { + if (details.url != URL) { + return; + } + + countAfter++; + + info(`onResponseStarted ${details.url}`); + let found = false; + for (let { name, value } of details.responseHeaders) { + info(`Saw header ${name} '${value}'`); + if (name == "set-cookie") { + equal(value, "dinky=1", "Cookie is correct"); + found = true; + } + } + ok(found, "Saw cookie header"); + equal(countAfter, 1, "onResponseStarted hit once"); +} + +add_task(async function setup() { + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function filter_urls() { + // First load the URL so that we set cookie foopy=1. + let contentPage = await ExtensionTestUtils.loadContentPage(URL); + await contentPage.close(); + + // Now load with WebRequest set up. + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, null, [ + "blocking", + "requestHeaders", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, null, [ + "responseHeaders", + ]); + + contentPage = await ExtensionTestUtils.loadContentPage(URL); + await contentPage.close(); + + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js new file mode 100644 index 0000000000..156ba6267d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js @@ -0,0 +1,182 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.import( + "resource://gre/modules/WebRequest.jsm" +); + +var { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = "http://example.com/data/"; +const URL = BASE + "/file_WebRequest_page2.html"; + +var requested = []; + +function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + if (details.url.startsWith(BASE)) { + requested.push(details.url); + } +} + +var sendHeaders = []; + +function onBeforeSendHeaders(details) { + info(`onBeforeSendHeaders ${details.url}`); + if (details.url.startsWith(BASE)) { + sendHeaders.push(details.url); + } +} + +var completed = []; + +function onResponseStarted(details) { + if (details.url.startsWith(BASE)) { + completed.push(details.url); + } +} + +const expected_urls = [ + BASE + "/file_style_good.css", + BASE + "/file_style_bad.css", + BASE + "/file_style_redirect.css", +]; + +function resetExpectations() { + requested.length = 0; + sendHeaders.length = 0; + completed.length = 0; +} + +function removeDupes(list) { + let j = 0; + for (let i = 1; i < list.length; i++) { + if (list[i] != list[j]) { + j++; + if (i != j) { + list[j] = list[i]; + } + } + } + list.length = j + 1; +} + +function compareLists(list1, list2, kind) { + list1.sort(); + removeDupes(list1); + list2.sort(); + removeDupes(list2); + equal(String(list1), String(list2), `${kind} URLs correct`); +} + +async function openAndCloseContentPage(url) { + let contentPage = await ExtensionTestUtils.loadContentPage(URL); + // Clear the sheet cache so that it doesn't interact with following tests: A + // stylesheet with the same URI loaded from the same origin doesn't otherwise + // guarantee that onBeforeRequest and so on happen, because it may not need + // to go through necko at all. + await contentPage.spawn(null, () => + content.windowUtils.clearSharedStyleSheetCache() + ); + await contentPage.close(); +} + +add_task(async function setup() { + // Disable rcwn to make cache behavior deterministic. + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function filter_urls() { + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]) }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, expected_urls, "requested"); + compareLists(sendHeaders, expected_urls, "sendHeaders"); + compareLists(completed, expected_urls, "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_types() { + resetExpectations(); + let filter = { types: ["stylesheet"] }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, expected_urls, "requested"); + compareLists(sendHeaders, expected_urls, "sendHeaders"); + compareLists(completed, expected_urls, "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_windowId() { + resetExpectations(); + // Check that adding windowId will exclude non-matching requests. + // test_ext_webrequest_filter.html provides coverage for matching requests. + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), windowId: 0 }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, [], "requested"); + compareLists(sendHeaders, [], "sendHeaders"); + compareLists(completed, [], "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_tabId() { + resetExpectations(); + // Check that adding tabId will exclude non-matching requests. + // test_ext_webrequest_filter.html provides coverage for matching requests. + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), tabId: 0 }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, [], "requested"); + compareLists(sendHeaders, [], "sendHeaders"); + compareLists(completed, [], "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini new file mode 100644 index 0000000000..332921e685 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini @@ -0,0 +1,13 @@ +# Similar to xpcshell-common.ini, except tests here only run +# when e10s is enabled (with or without out-of-process extensions). + +[test_ext_webRequest_filterResponseData.js] +# tsan failure is for test_filter_301 timing out, bug 1674773 +skip-if = tsan || os == "android" && debug +[test_ext_webRequest_redirect_StreamFilter.js] +[test_ext_webRequest_responseBody.js] +skip-if = os == "android" && debug +[test_ext_webRequest_startup_StreamFilter.js] +skip-if = os == "android" && debug +[test_ext_webRequest_viewsource_StreamFilter.js] +skip-if = tsan # Bug 1683730
\ No newline at end of file diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini new file mode 100644 index 0000000000..32d76194bb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini @@ -0,0 +1,260 @@ +[DEFAULT] +# Some tests of downloads.download() expect a file picker, which is only shown +# by default when the browser.download.useDownloadDir pref is set to true. This +# is the case on desktop Firefox, but not on Thunderbird. +# Force pref value to true to get download tests to pass on Thunderbird. +prefs = browser.download.useDownloadDir=true + +[test_change_remote_mode.js] +[test_ext_MessageManagerProxy.js] +skip-if = os == 'android' # Bug 1545439 +[test_ext_activityLog.js] +[test_ext_alarms.js] +[test_ext_alarms_does_not_fire.js] +[test_ext_alarms_periodic.js] +[test_ext_alarms_replaces.js] +[test_ext_api_permissions.js] +[test_ext_background_api_injection.js] +[test_ext_background_early_shutdown.js] +[test_ext_background_generated_load_events.js] +[test_ext_background_generated_reload.js] +[test_ext_background_global_history.js] +skip-if = os == "android" # Android does not use Places for history. +[test_ext_background_private_browsing.js] +[test_ext_background_runtime_connect_params.js] +[test_ext_background_sub_windows.js] +[test_ext_background_teardown.js] +[test_ext_background_telemetry.js] +[test_ext_background_window_properties.js] +skip-if = os == "android" +[test_ext_browserSettings.js] +[test_ext_browserSettings_homepage.js] +skip-if = appname == "thunderbird" || os == "android" +[test_ext_browsingData.js] +[test_ext_browsingData_cookies_cache.js] +[test_ext_browsingData_cookies_cookieStoreId.js] +[test_ext_captivePortal.js] +# As with test_captive_portal_service.js, we use the same limits here. +skip-if = appname == "thunderbird" || os == "android" || (os == "mac" && debug) # CP service is disabled on Android, macosx1014/debug due to 1564534 +run-sequentially = node server exceptions dont replay well +[test_ext_captivePortal_url.js] +# As with test_captive_portal_service.js, we use the same limits here. +skip-if = appname == "thunderbird" || os == "android" || (os == "mac" && debug) # CP service is disabled on Android, macosx1014/debug due to 1564534 +run-sequentially = node server exceptions dont replay well +[test_ext_cookieBehaviors.js] +skip-if = appname == "thunderbird" || tsan # Bug 1683730 +[test_ext_cookies_firstParty.js] +skip-if = appname == "thunderbird" || os == "android" || tsan # Android: Bug 1680132. tsan: Bug 1683730 +[test_ext_cookies_samesite.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_content_security_policy.js] +skip-if = (os == "win" && debug) #Bug 1485567 +[test_ext_contentscript_api_injection.js] +[test_ext_contentscript_async_loading.js] +skip-if = os == 'android' && debug # The generated script takes too long to load on Android debug +[test_ext_contentscript_context.js] +skip-if = tsan # Bug 1683730 +[test_ext_contentscript_context_isolation.js] +skip-if = tsan # Bug 1683730 +[test_ext_contentscript_create_iframe.js] +[test_ext_contentscript_csp.js] +[test_ext_contentscript_css.js] +[test_ext_contentscript_exporthelpers.js] +[test_ext_contentscript_in_background.js] +[test_ext_contentscript_restrictSchemes.js] +[test_ext_contentscript_teardown.js] +skip-if = tsan # Bug 1683730 +[test_ext_contentscript_unregister_during_loadContentScript.js] +[test_ext_contentscript_xml_prettyprint.js] +[test_ext_contextual_identities.js] +skip-if = appname == "thunderbird" || os == "android" # Containers are not exposed to android. +[test_ext_debugging_utils.js] +[test_ext_dns.js] +skip-if = socketprocess_networking || os == "android" # Android: Bug 1680132 +[test_ext_downloads.js] +[test_ext_downloads_cookies.js] +skip-if = os == "android" # downloads API needs to be implemented in GeckoView - bug 1538348 +[test_ext_downloads_download.js] +skip-if = appname == "thunderbird" || os == "android" || tsan # tsan: bug 1612707 +[test_ext_downloads_misc.js] +skip-if = os == "android" || (os=='linux' && bits==32) || tsan # linux32: bug 1324870, tsan: bug 1612707 +[test_ext_downloads_private.js] +skip-if = os == "android" +[test_ext_downloads_search.js] +skip-if = os == "android" || tsan # tsan: bug 1612707 +[test_ext_downloads_urlencoded.js] +skip-if = os == "android" +[test_ext_error_location.js] +[test_ext_eventpage_warning.js] +[test_ext_experiments.js] +[test_ext_extension.js] +[test_ext_extensionPreferencesManager.js] +[test_ext_extensionSettingsStore.js] +[test_ext_extension_content_telemetry.js] +skip-if = os == "android" # checking for telemetry needs to be updated: 1384923 +[test_ext_extension_startup_failure.js] +[test_ext_extension_startup_telemetry.js] +[test_ext_file_access.js] +[test_ext_geckoProfiler_control.js] +skip-if = os == "android" || tsan # Not shipped on Android. tsan: bug 1612707 +[test_ext_geturl.js] +[test_ext_idle.js] +[test_ext_incognito.js] +skip-if = appname == "thunderbird" +[test_ext_l10n.js] +[test_ext_localStorage.js] +[test_ext_management.js] +skip-if = (os == "win" && !debug) #Bug 1419183 disable on Windows +[test_ext_management_uninstall_self.js] +[test_ext_messaging_startup.js] +skip-if = appname == "thunderbird" || (os == "android" && debug) +[test_ext_networkStatus.js] +[test_ext_notifications_incognito.js] +skip-if = appname == "thunderbird" +[test_ext_notifications_unsupported.js] +[test_ext_onmessage_removelistener.js] +skip-if = true # This test no longer tests what it is meant to test. +[test_ext_permission_xhr.js] +[test_ext_persistent_events.js] +[test_ext_privacy.js] +skip-if = appname == "thunderbird" || (os == "android" && debug) || (os == "linux" && !debug) #Bug 1625455 +[test_ext_privacy_disable.js] +skip-if = appname == "thunderbird" +[test_ext_privacy_update.js] +[test_ext_proxy_authorization_via_proxyinfo.js] +skip-if = true # Bug 1622433 needs h2 proxy implementation +[test_ext_proxy_config.js] +skip-if = appname == "thunderbird" || os == "android" # Android: Bug 1680132 +[test_ext_proxy_onauthrequired.js] +[test_ext_proxy_settings.js] +skip-if = appname == "thunderbird" || os == "android" # proxy settings are not supported on android +[test_ext_proxy_socks.js] +skip-if = socketprocess_networking +run-sequentially = TCPServerSocket fails otherwise +[test_ext_proxy_speculative.js] +skip-if = ccov && os == 'linux' # bug 1607581 +[test_ext_proxy_startup.js] +skip-if = ccov && os == 'linux' # bug 1607581 +[test_ext_redirects.js] +skip-if = os == "android" && debug +[test_ext_runtime_connect_no_receiver.js] +[test_ext_runtime_getBrowserInfo.js] +[test_ext_runtime_getPlatformInfo.js] +[test_ext_runtime_id.js] +skip-if = ccov && os == 'linux' # bug 1607581 +[test_ext_runtime_messaging_self.js] +[test_ext_runtime_onInstalled_and_onStartup.js] +[test_ext_runtime_ports.js] +[test_ext_runtime_ports_gc.js] +[test_ext_runtime_sendMessage.js] +[test_ext_runtime_sendMessage_errors.js] +[test_ext_runtime_sendMessage_multiple.js] +[test_ext_runtime_sendMessage_no_receiver.js] +[test_ext_same_site_cookies.js] +[test_ext_same_site_redirects.js] +[test_ext_sandbox_var.js] +[test_ext_schema.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_shared_workers.js] +[test_ext_shutdown_cleanup.js] +[test_ext_simple.js] +[test_ext_startupData.js] +[test_ext_startup_cache.js] +skip-if = os == "android" +[test_ext_startup_perf.js] +[test_ext_startup_request_handler.js] +[test_ext_storage_local.js] +skip-if = os == "android" && debug +[test_ext_storage_idb_data_migration.js] +skip-if = appname == "thunderbird" || (os == "android" && debug) +[test_ext_storage_content_local.js] +skip-if = os == "android" && debug +[test_ext_storage_content_sync.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_storage_content_sync_kinto.js] +skip-if = os == "android" && debug +[test_ext_storage_quota_exceeded_errors.js] +skip-if = os == "android" # Bug 1564871 +[test_ext_storage_managed.js] +skip-if = os == "android" +[test_ext_storage_managed_policy.js] +skip-if = appname == "thunderbird" || os == "android" +[test_ext_storage_sanitizer.js] +skip-if = appname == "thunderbird" || os == "android" # Android: Bug 1680132 +[test_ext_storage_sync.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_storage_sync_kinto.js] +skip-if = appname == "thunderbird" || os == "android" +[test_ext_storage_sync_kinto_crypto.js] +skip-if = appname == "thunderbird" || os == "android" +[test_ext_storage_tab.js] +[test_ext_storage_telemetry.js] +skip-if = os == "android" # checking for telemetry needs to be updated: 1384923 +[test_ext_tab_teardown.js] +skip-if = os == 'android' # Bug 1258975 on android. +[test_ext_telemetry.js] +[test_ext_trustworthy_origin.js] +[test_ext_unlimitedStorage.js] +[test_ext_unload_frame.js] +skip-if = true # Too frequent intermittent failures +[test_ext_userScripts.js] +[test_ext_userScripts_exports.js] +[test_ext_userScripts_telemetry.js] +[test_ext_webRequest_auth.js] +skip-if = os == "android" && debug +[test_ext_webRequest_cached.js] +skip-if = os == "android" # Bug 1573511 +[test_ext_webRequest_cancelWithReason.js] +[test_ext_webRequest_download.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_webRequest_filterTypes.js] +[test_ext_webRequest_from_extension_page.js] +[test_ext_webRequest_incognito.js] +skip-if = os == "android" && debug +[test_ext_webRequest_filter_urls.js] +[test_ext_webRequest_host.js] +skip-if = os == "android" && debug +[test_ext_webRequest_mergecsp.js] +skip-if = tsan # Bug 1683730 +[test_ext_webRequest_permission.js] +skip-if = os == "android" && debug +[test_ext_webRequest_redirect_mozextension.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_webRequest_requestSize.js] +skip-if = socketprocess_networking +[test_ext_webRequest_set_cookie.js] +skip-if = appname == "thunderbird" +[test_ext_webRequest_startup.js] +skip-if = os == "android" # bug 1683159 +[test_ext_webRequest_style_cache.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_webRequest_suspend.js] +[test_ext_webRequest_userContextId.js] +[test_ext_webRequest_viewsource.js] +[test_ext_webRequest_webSocket.js] +skip-if = appname == "thunderbird" +[test_ext_xhr_capabilities.js] +[test_native_manifests.js] +subprocess = true +skip-if = os == "android" +[test_ext_permissions.js] +skip-if = appname == "thunderbird" || os == "android" # Bug 1350559 +[test_ext_permissions_api.js] +skip-if = appname == "thunderbird" || os == "android" # Bug 1350559 +[test_ext_permissions_migrate.js] +skip-if = appname == "thunderbird" || os == "android" # Bug 1350559 +[test_ext_permissions_uninstall.js] +skip-if = appname == "thunderbird" || os == "android" # Bug 1350559 +[test_proxy_listener.js] +skip-if = appname == "thunderbird" +[test_proxy_incognito.js] +skip-if = os == "android" # incognito not supported on android +[test_proxy_info_results.js] +[test_proxy_userContextId.js] +[test_webRequest_ancestors.js] +[test_webRequest_cookies.js] +[test_webRequest_filtering.js] +[test_ext_brokenlinks.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_performance_counters.js] +skip-if = appname == "thunderbird" || os == "android" diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini new file mode 100644 index 0000000000..0950f7a9d3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini @@ -0,0 +1,22 @@ +[DEFAULT] +prefs = + javascript.options.experimental.private_fields=true + +[test_ext_i18n.js] +skip-if = os == "android" || (os == "win" && debug) || (os == "linux") +[test_ext_i18n_css.js] +[test_ext_contentscript.js] +[test_ext_contentscript_about_blank_start.js] +[test_ext_contentscript_canvas_tainting.js] +[test_ext_contentscript_scriptCreated.js] +[test_ext_contentscript_triggeringPrincipal.js] +skip-if = os == "android" || (os == "win" && debug) || tsan || socketprocess_networking # Windows: Bug 1438796, tsan: bug 1612707, Android: Bug 1680132 +[test_ext_contentscript_xrays.js] +[test_ext_contentScripts_register.js] +[test_ext_contexts_gc.js] +[test_ext_adoption_with_xrays.js] +[test_ext_adoption_with_private_field_xrays.js] +skip-if = !nightly_build +[test_ext_shadowdom.js] +skip-if = ccov && os == 'linux' # bug 1607581 +[test_ext_web_accessible_resources.js] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini new file mode 100644 index 0000000000..228492d00b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini @@ -0,0 +1,28 @@ +[DEFAULT] +head = head.js head_e10s.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +dupe-manifest = +support-files = + data/** + xpcshell-content.ini +tags = webextensions webextensions-e10s + +# services.settings.server/default_bucket: +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the default remote settings bucket pref name to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=http://localhost:7777/remote-settings-dummy/v1 + services.settings.default_bucket=nonexistent-bucket-foo + +[include:xpcshell-common-e10s.ini] +[include:xpcshell-content.ini] + +# Tests that need to run with e10s only must NOT be placed here, +# but in xpcshell-common-e10s.ini. +# A test here will only run on one configuration, e10s + in-process extensions, +# while the primary target is e10s + out-of-process extensions. +# xpcshell-common-e10s.ini runs in both configurations. diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini new file mode 100644 index 0000000000..2df5e54b68 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini @@ -0,0 +1,23 @@ +[DEFAULT] +head = head.js head_remote.js head_e10s.js head_legacy_ep.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +dupe-manifest = + +# services.settings.server/default_bucket: +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the default remote settings bucket pref name to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=http://localhost:7777/remote-settings-dummy/v1 + services.settings.default_bucket=nonexistent-bucket-foo + +# Bug 1646182: Test the legacy ExtensionPermission backend until we fully +# migrate to rkv +[test_ext_permissions.js] +[test_ext_permissions_api.js] +[test_ext_permissions_migrate.js] +[test_ext_permissions_uninstall.js] +[test_ext_proxy_config.js] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini new file mode 100644 index 0000000000..2ccd923230 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini @@ -0,0 +1,30 @@ +[DEFAULT] +head = head.js head_remote.js head_e10s.js head_telemetry.js head_sync.js head_storage.js +tail = +firefox-appdir = browser +skip-if = os == "android" +dupe-manifest = +support-files = + data/** + xpcshell-content.ini +tags = webextensions remote-webextensions + +# services.settings.server/default_bucket: +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the default remote settings bucket pref name to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=http://localhost:7777/remote-settings-dummy/v1 + services.settings.default_bucket=nonexistent-bucket-foo + +[include:xpcshell-common.ini] +[include:xpcshell-common-e10s.ini] +[include:xpcshell-content.ini] + +[test_ext_contentscript_perf_observers.js] # Inexplicably, PerformanceObserver used in the test doesn't fire in non-e10s mode. +skip-if = tsan +[test_ext_contentscript_xorigin_frame.js] +[test_WebExtensionContentScript.js] +[test_ext_ipcBlob.js] +skip-if = os == 'android' && processor == 'x86_64' diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..27086a31ef --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini @@ -0,0 +1,89 @@ +[DEFAULT] +head = head.js head_telemetry.js head_sync.js head_storage.js +firefox-appdir = browser +dupe-manifest = +support-files = + data/** + xpcshell-content.ini +tags = webextensions in-process-webextensions + +# services.settings.server/default_bucket: +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the default remote settings bucket pref name to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=http://localhost:7777/remote-settings-dummy/v1 + services.settings.default_bucket=nonexistent-bucket-foo + +# This file contains tests which are not affected by multi-process +# configuration, or do not support out-of-process content or extensions +# for one reason or another. +# +# Tests which are affected by remote content or remote extensions should +# go in one of: +# +# - xpcshell-common.ini +# For tests which should run in all configurations. +# - xpcshell-common-e10s.ini +# For tests which should run in all configurations where e10s is enabled. +# - xpcshell-remote.ini +# For tests which should only run with both remote extensions and remote content. +# - xpcshell-content.ini +# For tests which rely on content pages, and should run in all configurations. +# - xpcshell-e10s.ini +# For tests which rely on content pages, and should only run with remote content +# but in-process extensions. + +[test_ExtensionStorageSync_migration_kinto.js] +skip-if = os == 'android' # Not shipped on Android +[test_MatchPattern.js] +[test_StorageSyncService.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_WebExtensionPolicy.js] + +[test_csp_custom_policies.js] +[test_csp_validator.js] +[test_ext_contexts.js] +[test_ext_json_parser.js] +[test_ext_geckoProfiler_schema.js] +skip-if = os == 'android' # Not shipped on Android +[test_ext_manifest.js] +skip-if = toolkit == 'android' # browser_action icon testing not supported on android +[test_ext_manifest_content_security_policy.js] +[test_ext_manifest_incognito.js] +[test_ext_indexedDB_principal.js] +[test_ext_manifest_minimum_chrome_version.js] +[test_ext_manifest_minimum_opera_version.js] +[test_ext_manifest_themes.js] +[test_ext_permission_warnings.js] +[test_ext_schemas.js] +[test_ext_schemas_roots.js] +[test_ext_schemas_async.js] +[test_ext_schemas_allowed_contexts.js] +[test_ext_schemas_interactive.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_schemas_manifest_permissions.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_schemas_privileged.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_schemas_revoke.js] +[test_ext_test_mock.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_test_wrapper.js] +[test_ext_unknown_permissions.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_webRequest_urlclassification.js] +[test_extension_permissions_migration.js] +[test_load_all_api_modules.js] +[test_locale_converter.js] +[test_locale_data.js] +skip-if = os == 'android' && processor == 'x86_64' + +[test_ext_runtime_sendMessage_args.js] +skip-if = os == 'android' && processor == 'x86_64' + +[include:xpcshell-common.ini] +run-if = os == 'android' # Android has no remote extensions, Bug 1535365 +[include:xpcshell-content.ini] +run-if = os == 'android' # Android has no remote extensions, Bug 1535365 |