diff options
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell')
369 files changed, 83875 insertions, 0 deletions
diff --git a/toolkit/components/extensions/test/xpcshell/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..60d784b53c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,13 @@ +"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, + // Many parts of WebExtensions test definitions (e.g. content scripts) also + // interact with the browser environment, so define that here as we don't + // have an easy way to handle per-function/scope usage yet. + browser: true, + }, +}; diff --git a/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.jsm b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.jsm new file mode 100644 index 0000000000..47120687e0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherChild.jsm @@ -0,0 +1,68 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["TestWorkerWatcherChild"]; + +const { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +const lazy = {}; + +XPCOMUtils.defineLazyServiceGetter( + lazy, + "wdm", + "@mozilla.org/dom/workers/workerdebuggermanager;1", + "nsIWorkerDebuggerManager" +); + +class TestWorkerWatcherChild extends JSProcessActorChild { + async receiveMessage(msg) { + switch (msg.name) { + case "Test:StartWatchingWorkers": + this.startWatchingWorkers(); + break; + case "Test:StopWatchingWorkers": + this.stopWatchingWorkers(); + break; + default: + // Ensure the test case will fail if this JSProcessActorChild does receive + // unexpected messages. + return Promise.reject( + new Error(`Unexpected message received: ${msg.name}`) + ); + } + } + + startWatchingWorkers() { + if (!this._workerDebuggerListener) { + const actor = this; + this._workerDebuggerListener = { + onRegister(dbg) { + actor.sendAsyncMessage("Test:WorkerSpawned", { + workerType: dbg.type, + workerUrl: dbg.url, + }); + }, + onUnregister(dbg) { + actor.sendAsyncMessage("Test:WorkerTerminated", { + workerType: dbg.type, + workerUrl: dbg.url, + }); + }, + }; + + lazy.wdm.addListener(this._workerDebuggerListener); + } + } + + stopWatchingWorkers() { + if (this._workerDebuggerListener) { + lazy.wdm.removeListener(this._workerDebuggerListener); + this._workerDebuggerListener = null; + } + } + + willDestroy() { + this.stopWatchingWorkers(); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.jsm b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.jsm new file mode 100644 index 0000000000..bf7836385c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/TestWorkerWatcherParent.jsm @@ -0,0 +1,24 @@ +"use strict"; + +var EXPORTED_SYMBOLS = ["TestWorkerWatcherParent"]; + +class TestWorkerWatcherParent extends JSProcessActorParent { + constructor() { + super(); + // This is set by the test helper that does use these process actors. + this.eventEmitter = null; + } + + receiveMessage(msg) { + switch (msg.name) { + case "Test:WorkerSpawned": + this.eventEmitter?.emit("worker-spawned", msg.data); + break; + case "Test:WorkerTerminated": + this.eventEmitter?.emit("worker-terminated", msg.data); + break; + default: + throw new Error(`Unexpected message received: ${msg.name}`); + } + } +} 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_content_script_errors.html b/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html new file mode 100644 index 0000000000..da1d1c32bc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_content_script_errors.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + </head> + <body>Content script errors</body> +</html> 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..f8369ae574 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html @@ -0,0 +1,36 @@ +<!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; + let win = iframe.contentWindow; + 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 win.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_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html new file mode 100644 index 0000000000..705350d55c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_with_iframe.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8"> + <title>file with iframe</title> + </head> + <body> + <div id="test"></div> + <iframe src="./file_sample.html"></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..14d8b74b66 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head.js @@ -0,0 +1,353 @@ +"use strict"; +/* exported createHttpServer, cleanupDir, clearCache, optionalPermissionsPromptHandler, promiseConsoleOutput, + promiseQuotaManagerServiceReset, promiseQuotaManagerServiceClear, + runWithPrefs, testEnv, withHandlingUserInput, resetHandlingUserInput, + assertPersistentListeners, promiseExtensionEvent, assertHasPersistedScriptsCachedFlag, + assertIsPersistedScriptsCachedFlag +*/ + +var { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); +var { + clearInterval, + clearTimeout, + setInterval, + setIntervalWithTarget, + setTimeout, + setTimeoutWithTarget, +} = ChromeUtils.importESModule("resource://gre/modules/Timer.sys.mjs"); +var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.import( + "resource://testing-common/AddonTestUtils.jsm" +); + +ChromeUtils.defineESModuleGetters(this, { + ContentTask: "resource://testing-common/ContentTask.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + PromiseTestUtils: "resource://testing-common/PromiseTestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + Extension: "resource://gre/modules/Extension.jsm", + ExtensionData: "resource://gre/modules/Extension.jsm", + ExtensionParent: "resource://gre/modules/ExtensionParent.jsm", + ExtensionTestUtils: "resource://testing-common/ExtensionXPCShellUtils.jsm", + MessageChannel: "resource://testing-common/MessageChannel.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", + Schemas: "resource://gre/modules/Schemas.jsm", +}); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; + +// https_first automatically upgrades http to https, but the tests are not +// designed to expect that. And it is not easy to change that because +// nsHttpServer does not support https (bug 1742061). So disable https_first. +Services.prefs.setBoolPref("dom.security.https_first", false); + +// 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_setup(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://testing-common/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; + }); +} + +// Optional Permission prompt handling +const optionalPermissionsPromptHandler = { + sawPrompt: false, + acceptPrompt: false, + + init() { + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + true + ); + Services.obs.addObserver(this, "webextension-optional-permission-prompt"); + registerCleanupFunction(() => { + Services.obs.removeObserver( + this, + "webextension-optional-permission-prompt" + ); + Services.prefs.clearUserPref( + "extensions.webextOptionalPermissionPrompts" + ); + }); + }, + + observe(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + this.sawPrompt = true; + let { resolve } = subject.wrappedJSObject; + resolve(this.acceptPrompt); + } + }, +}; + +function promiseExtensionEvent(wrapper, event) { + return new Promise(resolve => { + wrapper.extension.once(event, (...args) => resolve(args)); + }); +} + +async function assertHasPersistedScriptsCachedFlag(ext) { + const { StartupCache } = ExtensionParent; + const allCachedGeneral = StartupCache._data.get("general"); + equal( + allCachedGeneral + .get(ext.id) + ?.get(ext.version) + ?.get("scripting") + ?.has("hasPersistedScripts"), + true, + "Expect the StartupCache to include hasPersistedScripts flag" + ); +} + +async function assertIsPersistentScriptsCachedFlag(ext, expectedValue) { + const { StartupCache } = ExtensionParent; + const allCachedGeneral = StartupCache._data.get("general"); + equal( + allCachedGeneral + .get(ext.id) + ?.get(ext.version) + ?.get("scripting") + ?.get("hasPersistedScripts"), + expectedValue, + "Expected cached value set on hasPersistedScripts flag" + ); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_dnr.js b/toolkit/components/extensions/test/xpcshell/head_dnr.js new file mode 100644 index 0000000000..c6856fde4a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_dnr.js @@ -0,0 +1,178 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* exported assertDNRStoreData, getDNRRule, getSchemaNormalizedRule, getSchemaNormalizedRules + */ + +XPCOMUtils.defineLazyModuleGetters(this, { + Schemas: "resource://gre/modules/Schemas.jsm", +}); + +function getDNRRule({ + id = 1, + priority = 1, + action = {}, + condition = {}, +} = {}) { + return { + id, + priority, + action: { + type: "block", + ...action, + }, + condition: { + ...condition, + }, + }; +} + +const getSchemaNormalizedRule = (extensionTestWrapper, value) => { + const { extension } = extensionTestWrapper; + const validationContext = { + url: extension.baseURI.spec, + principal: extension.principal, + logError: err => { + // We don't expect this test helper function to be called on invalid rules, + // and so we trigger an explicit test failure if we ever hit any. + Assert.ok( + false, + `Unexpected logError on normalizing DNR rule ${JSON.stringify( + value + )} - ${err}` + ); + }, + preprocessors: {}, + manifestVersion: extension.manifestVersion, + }; + + return Schemas.normalize( + value, + "declarativeNetRequest.Rule", + validationContext + ); +}; + +const getSchemaNormalizedRules = (extensionTestWrapper, rules) => { + return rules.map(rule => { + const normalized = getSchemaNormalizedRule(extensionTestWrapper, rule); + if (normalized.error) { + throw new Error( + `Unexpected DNR Rule normalization error: ${normalized.error}` + ); + } + return normalized.value; + }); +}; + +const assertDNRStoreData = async ( + dnrStore, + extensionTestWrapper, + expectedRulesets, + { assertIndividualRules = true } = {} +) => { + const extUUID = extensionTestWrapper.uuid; + const rule_resources = + extensionTestWrapper.extension.manifest.declarative_net_request + ?.rule_resources; + const expectedRulesetIds = Array.from(Object.keys(expectedRulesets)); + const expectedRulesetIndexesMap = expectedRulesetIds.reduce((acc, rsId) => { + acc.set( + rsId, + rule_resources.findIndex(rr => rr.id === rsId) + ); + return acc; + }, new Map()); + + ok( + dnrStore._dataPromises.has(extUUID), + "Got promise for the test extension DNR data being loaded" + ); + + await dnrStore._dataPromises.get(extUUID); + + ok(dnrStore._data.has(extUUID), "Got data for the test extension"); + + const dnrExtData = dnrStore._data.get(extUUID); + Assert.deepEqual( + { + schemaVersion: dnrExtData.schemaVersion, + extVersion: dnrExtData.extVersion, + }, + { + schemaVersion: dnrExtData.constructor.VERSION, + extVersion: extensionTestWrapper.extension.version, + }, + "Got the expected data schema version and extension version in the store data" + ); + Assert.deepEqual( + Array.from(dnrExtData.staticRulesets.keys()), + expectedRulesetIds, + "Got the enabled rulesets in the stored data staticRulesets Map" + ); + + for (const rulesetId of expectedRulesetIds) { + const expectedRulesetIdx = expectedRulesetIndexesMap.get(rulesetId); + const expectedRulesetRules = getSchemaNormalizedRules( + extensionTestWrapper, + expectedRulesets[rulesetId] + ); + const actualData = dnrExtData.staticRulesets.get(rulesetId); + equal( + actualData.idx, + expectedRulesetIdx, + `Got the expected ruleset index for ruleset id ${rulesetId}` + ); + + // Asserting an entire array of rules all at once will produce + // a big enough output to don't be immediately useful to investigate + // failures, asserting each rule individually would produce more + // readable assertion failure logs. + const assertRuleAtIdx = ruleIdx => + Assert.deepEqual( + actualData.rules[ruleIdx], + expectedRulesetRules[ruleIdx], + `Got the expected rule at index ${ruleIdx} for ruleset id "${rulesetId}"` + ); + + // Some tests may be using a big enough number of rules that + // the assertiongs would be producing a huge amount of log spam, + // and so for those tests we only explicitly assert the first + // and last rule and that the total amount of rules matches the + // expected number of rules (there are still other tests explicitly + // asserting all loaded rules). + if (assertIndividualRules) { + info( + `Verify the each individual rule loaded for ruleset id "${rulesetId}"` + ); + for (let ruleIdx = 0; ruleIdx < expectedRulesetRules.length; ruleIdx++) { + assertRuleAtIdx(ruleIdx); + } + } else { + // NOTE: Only asserting the first and last rule also helps to speed up + // the test is some slower builds when the number of expected rules is + // big enough (e.g. the test task verifying the enforced rule count limits + // was timing out in tsan build because asserting all indidual rules was + // taking long enough and the event page was being suspended on the idle + // timeout by the time we did run all these assertion and proceeding with + // the rest of the test task assertions), we still confirm that the total + // number of expected vs actual rules also matches right after these + // assertions. + info( + `Verify the first and last rules loaded for ruleset id "${rulesetId}"` + ); + const lastExpectedRuleIdx = expectedRulesetRules.length - 1; + for (const ruleIdx of [0, lastExpectedRuleIdx]) { + assertRuleAtIdx(ruleIdx); + } + } + + equal( + actualData.rules.length, + expectedRulesetRules.length, + `Got the expected number of rules loaded for ruleset id "${rulesetId}"` + ); + } +}; 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..c9e507ec79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js @@ -0,0 +1,152 @@ +/* -*- 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.defineESModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.sys.mjs", +}); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); +if (AppConstants.platform == "win") { + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs", + }); +} else { + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs", + }); +} + +const { Subprocess } = ChromeUtils.importESModule( + "resource://gre/modules/Subprocess.sys.mjs" +); + +// 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 pythonPath = await Subprocess.pathSearch(Services.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], + }; + + // Optionally, allow the test to change the manifest before writing. + script._hookModifyManifest?.(manifest); + + 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); + + let manifestPath = await writeManifest(script, scriptPath, batPath); + + 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_schemas.js b/toolkit/components/extensions/test/xpcshell/head_schemas.js new file mode 100644 index 0000000000..aeba2011fc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_schemas.js @@ -0,0 +1,127 @@ +"use strict"; + +/* exported Schemas, LocalAPIImplementation, SchemaAPIInterface, getContextWrapper */ + +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); + +let { LocalAPIImplementation, SchemaAPIInterface } = ExtensionCommon; + +const contextCloneScope = this; + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(context, namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + this.context = context; + } + + callFunction(args) { + this.context.tally("call", this.namespace, this.name, args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(args) { + this.context.tally("call", this.namespace, this.name, args); + } + + getProperty() { + this.context.tally("get", this.namespace, this.name); + } + + setProperty(value) { + this.context.tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + this.context.tally("addListener", this.namespace, this.name, [ + listener, + args, + ]); + } + + removeListener(listener) { + this.context.tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + this.context.tally("hasListener", this.namespace, this.name, [listener]); + } +} + +function getContextWrapper(manifestVersion = 2) { + return { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + + cloneScope: contextCloneScope, + + manifestVersion, + + permissions: new Set(), + tallied: null, + talliedErrors: [], + + tally(kind, ns, name, args) { + this.tallied = [kind, ns, name, args]; + }, + + verify(...args) { + Assert.equal(JSON.stringify(this.tallied), JSON.stringify(args)); + this.tallied = null; + }, + + checkErrors(errors) { + let { talliedErrors } = this; + 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; + }, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace( + /__MSG_(.*?)__/g, + (m0, m1) => `${m1.toUpperCase()}` + ); + }, + }, + + logError(message) { + this.talliedErrors.push(message); + }, + + hasPermission(permission) { + return this.permissions.has(permission); + }, + + shouldInject(ns, name, allowedContexts) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(this, namespace, name); + }, + }; +} diff --git a/toolkit/components/extensions/test/xpcshell/head_service_worker.js b/toolkit/components/extensions/test/xpcshell/head_service_worker.js new file mode 100644 index 0000000000..cefd26f6af --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_service_worker.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported TestWorkerWatcher */ + +XPCOMUtils.defineLazyModuleGetters(this, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.jsm", +}); + +// Ensure that the profile-after-change message has been notified, +// so that ServiceWokerRegistrar is going to be initialized, +// otherwise tests using a background service worker will fail. +// in debug builds because of an assertion failure triggered +// by ServiceWorkerRegistrar.cpp (due to not being initialized +// automatically on startup as in a real Firefox instance). +Services.obs.notifyObservers( + null, + "profile-after-change", + "force-serviceworkerrestart-init" +); + +// A test utility class used in the test case to watch for a given extension +// service worker being spawned and terminated (using the same kind of Firefox DevTools +// internals that about:debugging is using to watch the workers activity). +// +// NOTE: this helper class does also depends from the two jsm files where the +// Parent and Child TestWorkerWatcher actor is defined: +// +// - data/TestWorkerWatcherParent.jsm +// - data/TestWorkerWatcherChild.jsm +class TestWorkerWatcher extends ExtensionCommon.EventEmitter { + JS_ACTOR_NAME = "TestWorkerWatcher"; + + constructor(dataRelPath = "./data") { + super(); + this.dataRelPath = dataRelPath; + this.extensionProcess = null; + this.extensionProcessActor = null; + this.registerProcessActor(); + this.getAndWatchExtensionProcess(); + // Observer child process creation and shutdown if the extension + // are meant to run in a child process. + Services.obs.addObserver(this, "ipc:content-created"); + Services.obs.addObserver(this, "ipc:content-shutdown"); + } + + async destroy() { + await this.stopWatchingWorkers(); + ChromeUtils.unregisterProcessActor(this.JS_ACTOR_NAME); + } + + get swm() { + return Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + } + + getRegistration(extension) { + return this.swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + } + + watchExtensionServiceWorker(extension) { + // These events are emitted by TestWatchExtensionWorkersParent. + const promiseWorkerSpawned = this.waitForEvent("worker-spawned", extension); + const promiseWorkerTerminated = this.waitForEvent( + "worker-terminated", + extension + ); + + // Terminate the worker sooner by settng the idle_timeout to 0, + // then clear the pref as soon as the worker has been terminated. + const terminate = () => { + promiseWorkerTerminated.then(() => { + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + }); + Services.prefs.setIntPref("dom.serviceWorkers.idle_timeout", 0); + const swReg = this.getRegistration(extension); + // If the active worker is already active, we have to make sure the new value + // set on the idle_timeout pref is picked up by ServiceWorkerPrivate::ResetIdleTimeout. + swReg.activeWorker?.attachDebugger(); + swReg.activeWorker?.detachDebugger(); + return promiseWorkerTerminated; + }; + + return { + promiseWorkerSpawned, + promiseWorkerTerminated, + terminate, + }; + } + + // Methods only used internally. + + waitForEvent(event, extension) { + return new Promise(resolve => { + const listener = (_eventName, data) => { + if (!data.workerUrl.startsWith(extension.extension?.principal.spec)) { + return; + } + this.off(event, listener); + resolve(data); + }; + + this.on(event, listener); + }); + } + + registerProcessActor() { + const { JS_ACTOR_NAME } = this; + ChromeUtils.registerProcessActor(JS_ACTOR_NAME, { + parent: { + moduleURI: `resource://testing-common/${JS_ACTOR_NAME}Parent.jsm`, + }, + child: { + moduleURI: `resource://testing-common/${JS_ACTOR_NAME}Child.jsm`, + }, + }); + } + + startWatchingWorkers() { + if (!this.extensionProcessActor) { + return; + } + this.extensionProcessActor.eventEmitter = this; + return this.extensionProcessActor.sendQuery("Test:StartWatchingWorkers"); + } + + stopWatchingWorkers() { + if (!this.extensionProcessActor) { + return; + } + this.extensionProcessActor.eventEmitter = null; + return this.extensionProcessActor.sendQuery("Test:StopWatchingWorkers"); + } + + getAndWatchExtensionProcess() { + const extensionProcess = ChromeUtils.getAllDOMProcesses().find(p => { + return p.remoteType === "extension"; + }); + if (extensionProcess !== this.extensionProcess) { + this.extensionProcess = extensionProcess; + this.extensionProcessActor = extensionProcess + ? extensionProcess.getActor(this.JS_ACTOR_NAME) + : null; + this.startWatchingWorkers(); + } + } + + observe(subject, topic, childIDString) { + // Keep the watched process and related test child process actor updated + // when a process is created or destroyed. + this.getAndWatchExtensionProcess(); + } +} 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..dca0780367 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_storage.js @@ -0,0 +1,1330 @@ +/* -*- 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() { + async function background() { + browser.test.sendMessage( + "initialItems", + await browser.storage.sync.get(null) + ); + await browser.storage.sync.set({ a: "b" }); + browser.test.notifyPass("set-works"); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + permissions: ["storage"], + }, + background: `(${background})()`, + }); + } + + ok( + Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to true" + ); + + let extension1 = loadExtension(); + + await extension1.startup(); + + Assert.deepEqual( + await extension1.awaitMessage("initialItems"), + {}, + "No stored items at first" + ); + + await extension1.awaitFinish("set-works"); + await extension1.unload(); + + let extension2 = loadExtension(); + + await extension2.startup(); + + Assert.deepEqual( + await extension2.awaitMessage("initialItems"), + { a: "b" }, + "Stored items available after restart" + ); + + 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 will not work 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"], + browser_specific_settings: { 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"], + browser_specific_settings: { 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(); +} + +async function test_storage_change_event_page(areaName) { + async function testOnChanged(targetIsStorageArea) { + function backgroundTestStorageTopNamespace(areaName) { + browser.storage.onChanged.addListener((changes, area) => { + browser.test.assertEq(area, areaName, "Expected areaName"); + browser.test.assertEq( + JSON.stringify(changes), + `{"storageKey":{"newValue":"newStorageValue"}}`, + "Expected changes" + ); + browser.test.sendMessage("onChanged_was_fired"); + }); + } + function backgroundTestStorageAreaNamespace(areaName) { + browser.storage[areaName].onChanged.addListener((changes, ...args) => { + browser.test.assertEq(args.length, 0, "no more args after changes"); + browser.test.assertEq( + JSON.stringify(changes), + `{"storageKey":{"newValue":"newStorageValue"}}`, + `Expected changes via ${areaName}.onChanged event` + ); + browser.test.sendMessage("onChanged_was_fired"); + }); + } + let background, onChangedName; + if (targetIsStorageArea) { + // Test storage.local.onChanged / storage.sync.onChanged. + background = backgroundTestStorageAreaNamespace; + onChangedName = `${areaName}.onChanged`; + } else { + background = backgroundTestStorageTopNamespace; + onChangedName = "onChanged"; + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + background: { persistent: false }, + }, + background: `(${background})("${areaName}")`, + files: { + "trigger-change.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="trigger-change.js"></script> + `, + "trigger-change.js": async () => { + let areaName = location.search.slice(1); + await browser.storage[areaName].set({ + storageKey: "newStorageValue", + }); + browser.test.sendMessage("tried_to_trigger_change"); + }, + }, + }); + await extension.startup(); + assertPersistentListeners(extension, "storage", onChangedName, { + primed: false, + }); + + await extension.terminateBackground(); + assertPersistentListeners(extension, "storage", onChangedName, { + primed: true, + }); + + // Now trigger the event + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/trigger-change.html?${areaName}` + ); + await extension.awaitMessage("tried_to_trigger_change"); + await contentPage.close(); + await extension.awaitMessage("onChanged_was_fired"); + + assertPersistentListeners(extension, "storage", onChangedName, { + primed: false, + }); + await extension.unload(); + } + + async function testFn() { + // Test browser.storage.onChanged.addListener + await testOnChanged(/* targetIsStorageArea */ false); + // Test browser.storage.local.onChanged.addListener + // and browser.storage.sync.onChanged.addListener, depending on areaName. + await testOnChanged(/* targetIsStorageArea */ true); + } + + return runWithPrefs([["extensions.eventPages.enabled", true]], testFn); +} 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..cec03a6a4e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_sync.js @@ -0,0 +1,66 @@ +/* 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 */ + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); + +class KintoExtContext extends ExtensionCommon.BaseContext { + constructor(principal) { + let fakeExtension = { id: "test@web.extension", manifestVersion: 2 }; + super("addon_parent", fakeExtension); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, { wantXrays: false }); + } + + 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..ccbdd4d787 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_telemetry.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"; + +/* exported IS_OOP, valueSum, clearHistograms, getSnapshots, promiseTelemetryRecorded */ + +ChromeUtils.defineESModuleGetters(this, { + ContentTaskUtils: "resource://testing-common/ContentTaskUtils.sys.mjs", +}); + +// Allows to run xpcshell telemetry test also on products (e.g. Thunderbird) where +// that telemetry wouldn't be actually collected in practice (but to be sure +// that it will work on those products as well by just adding the product in +// the telemetry metric definitions if it turns out we want to). +Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true +); + +const IS_OOP = Services.prefs.getBoolPref("extensions.webextensions.remote"); + +const WEBEXT_EVENTPAGE_RUNNING_TIME_MS = "WEBEXT_EVENTPAGE_RUNNING_TIME_MS"; +const WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID = + "WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID"; +const WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT = "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT"; +const WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID = + "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID"; + +// Keep this in sync with the order in Histograms.json for "WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT": +// the position of the category string determines the index of the values collected in the categorial +// histogram and so the existing labels should be kept in the exact same order and any new category +// to be added in the future should be appended to the existing ones. +const HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES = [ + "suspend", + "reset_other", + "reset_event", + "reset_listeners", + "reset_nativeapp", + "reset_streamfilter", +]; + +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}.` + ); +} + +function assertHistogramCategoryNotEmpty( + histogramId, + { category, categories, keyed, key }, + msg +) { + let message = msg; + + if (!msg) { + message = `Data recorded for histogram: ${histogramId}, category "${category}"`; + if (keyed) { + message += `, key "${key}"`; + } + } + + assertHistogramSnapshot( + histogramId, + { + keyed, + processSnapshot: snapshot => { + const categoryIndex = categories.indexOf(category); + if (keyed) { + return { + [key]: snapshot[key] + ? snapshot[key].values[categoryIndex] > 0 + : null, + }; + } + return snapshot.values[categoryIndex] > 0; + }, + expectedValue: keyed ? { [key]: true } : true, + }, + message + ); +} 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..06702670bc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/native_messaging.ini @@ -0,0 +1,19 @@ +[DEFAULT] +head = head.js head_e10s.js head_native_messaging.js head_telemetry.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 + apple_silicon # bug 1729540 +run-sequentially = very high failure rate in parallel +[test_ext_native_messaging_perf.js] +skip-if = tsan # Unreasonably slow, bug 1612707 + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[test_ext_native_messaging_unresponsive.js] diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js new file mode 100644 index 0000000000..d059d8606b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionShortcutKeyMap.js @@ -0,0 +1,142 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionShortcutKeyMap } = ChromeUtils.import( + "resource://gre/modules/ExtensionShortcuts.jsm" +); + +add_task(function test_ExtensionShortcutKeymap() { + const shortcutsMap = new ExtensionShortcutKeyMap(); + + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon1", "Command1"); + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon2", "Command2"); + shortcutsMap.recordShortcut("Ctrl+Alt+2", "Addon2", "Command3"); + // Empty shortcut not expected to be recorded, just ignored. + shortcutsMap.recordShortcut("", "Addon3", "Command4"); + + Assert.equal( + shortcutsMap.size, + 2, + "Got the expected number of shortcut entries" + ); + Assert.deepEqual( + { + shortcutWithTwoExtensions: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + shortcutWithOnlyOneExtension: shortcutsMap.getFirstAddonName( + "Ctrl+Alt+2" + ), + shortcutWithNoExtension: shortcutsMap.getFirstAddonName(""), + }, + { + shortcutWithTwoExtensions: "Addon1", + shortcutWithOnlyOneExtension: "Addon2", + shortcutWithNoExtension: null, + }, + "Got the expected results from getFirstAddonName calls" + ); + + Assert.deepEqual( + { + shortcutWithTwoExtensions: shortcutsMap.has("Ctrl+Shift+1"), + shortcutWithOnlyOneExtension: shortcutsMap.has("Ctrl+Alt+2"), + shortcutWithNoExtension: shortcutsMap.has(""), + }, + { + shortcutWithTwoExtensions: true, + shortcutWithOnlyOneExtension: true, + shortcutWithNoExtension: false, + }, + "Got the expected results from `has` calls" + ); + + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon1", "Command1"); + Assert.equal( + shortcutsMap.has("Ctrl+Shift+1"), + true, + "Expect shortcut to already exist after removing one duplicate" + ); + Assert.equal( + shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + "Addon2", + "Expect getFirstAddonName to return the remaining addon name" + ); + + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon2", "Command2"); + Assert.equal( + shortcutsMap.has("Ctrl+Shift+1"), + false, + "Expect shortcut to not exist anymore after removing last entry" + ); + Assert.equal(shortcutsMap.size, 1, "Got only one shortcut as expected"); + + shortcutsMap.clear(); + Assert.equal( + shortcutsMap.size, + 0, + "Got no shortcut as expected after clearing the map" + ); +}); + +// This test verify that ExtensionShortcutKeyMap does catch duplicated +// shortcut when the two modifiers strings are associated to the same +// key (in particular on macOS where Ctrl and Command keys are both translated +// in the same modifier in the keyboard shortcuts). +add_task(function test_PlatformShortcutString() { + const shortcutsMap = new ExtensionShortcutKeyMap(); + + // Make the class instance behave like it would while running on macOS. + // (this is just for unit testing purpose, there is a separate integration + // test exercising this behavior in a real "Manage Extension Shortcut" + // about:addons view and only running on macOS, skipped on other platforms). + shortcutsMap._os = "mac"; + + shortcutsMap.recordShortcut("Ctrl+Shift+1", "Addon1", "MacCommand1"); + + Assert.deepEqual( + { + hasWithCtrl: shortcutsMap.has("Ctrl+Shift+1"), + hasWithCommand: shortcutsMap.has("Command+Shift+1"), + }, + { + hasWithCtrl: true, + hasWithCommand: true, + }, + "Got the expected results from `has` calls" + ); + + Assert.deepEqual( + { + nameWithCtrl: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + nameWithCommand: shortcutsMap.getFirstAddonName("Command+Shift+1"), + }, + { + nameWithCtrl: "Addon1", + nameWithCommand: "Addon1", + }, + "Got the expected results from `getFirstAddonName` calls" + ); + + // Add a duplicate shortcut using Command instead of Ctrl and + // verify the expected behaviors. + shortcutsMap.recordShortcut("Command+Shift+1", "Addon2", "MacCommand2"); + Assert.equal(shortcutsMap.size, 1, "Got still one shortcut as expected"); + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon1", "MacCommand1"); + Assert.equal(shortcutsMap.size, 1, "Got still one shortcut as expected"); + Assert.deepEqual( + { + nameWithCtrl: shortcutsMap.getFirstAddonName("Ctrl+Shift+1"), + nameWithCommand: shortcutsMap.getFirstAddonName("Command+Shift+1"), + }, + { + nameWithCtrl: "Addon2", + nameWithCommand: "Addon2", + }, + "Got the expected results from `getFirstAddonName` calls" + ); + + // Remove the entry added with a shortcut using "Command" by using the + // equivalent shortcut using Ctrl. + shortcutsMap.removeShortcut("Ctrl+Shift+1", "Addon2", "MacCommand2"); + Assert.equal(shortcutsMap.size, 0, "Got no shortcut as expected"); +}); 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..1dc6239d9c --- /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 { extensionStorageSyncKinto: 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..1a57d0870e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js @@ -0,0 +1,602 @@ +/* -*- 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_MatchGlob_redundant_wildcards_backtracking() { + const slow_build = + AppConstants.DEBUG || AppConstants.TSAN || AppConstants.ASAN; + const first_limit = slow_build ? 200 : 20; + { + // Bug 1570868 - repeated * in tabs.query glob causes too much backtracking. + let title = `Monster${"*".repeat(99)}Mash`; + + // The first run could take longer than subsequent runs, as the DFA is lazily created. + let first_start = Date.now(); + let glob = new MatchGlob(title); + let first_matches = glob.matches(title); + let first_duration = Date.now() - first_start; + ok(first_matches, `Expected match: ${title}, ${title}`); + ok( + first_duration < first_limit, + `First matching duration: ${first_duration}ms (limit: ${first_limit}ms)` + ); + + let start = Date.now(); + let matches = glob.matches(title); + let duration = Date.now() - start; + + ok(matches, `Expected match: ${title}, ${title}`); + ok(duration < 10, `Matching duration: ${duration}ms`); + } + { + // Similarly with any continuous combination of ?**???****? wildcards. + let title = `Monster${"?*".repeat(99)}Mash`; + + // The first run could take longer than subsequent runs, as the DFA is lazily created. + let first_start = Date.now(); + let glob = new MatchGlob(title); + let first_matches = glob.matches(title); + let first_duration = Date.now() - first_start; + ok(first_matches, `Expected match: ${title}, ${title}`); + ok( + first_duration < first_limit, + `First matching duration: ${first_duration}ms (limit: ${first_limit}ms)` + ); + + let start = Date.now(); + let matches = glob.matches(title); + let duration = Date.now() - start; + + ok(matches, `Expected match: ${title}, ${title}`); + ok(duration < 10, `Matching duration: ${duration}ms`); + } +}); + +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..ef55ed37e8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js @@ -0,0 +1,274 @@ +/* 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, + payload: JSON.stringify({ + extId: "ext-2", + data: JSON.stringify({ + c: 1234, + }), + }), + }, + { + id: "guidBBB", + modified: 0.1, + payload: JSON.stringify({ + 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 payload. + let { value: outgoingEnvelopesAsJSON } = await promisify(area.apply); + let outgoingEnvelopes = outgoingEnvelopesAsJSON.map(json => JSON.parse(json)); + let parsedCleartexts = outgoingEnvelopes.map(e => JSON.parse(e.payload)); + 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"); + deepEqual( + parsedData[ext1Index], + { + a: "abc", + }, + "Should upload new data for ext-1" + ); + 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..2427c3c1be --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js @@ -0,0 +1,321 @@ +/* -*- 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")); + +async function test_url_matching({ + manifestVersion = 2, + allowedOrigins = [], + checkPermissions, + expectMatches, +}) { + let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + manifestVersion, + allowedOrigins: new MatchPatternSet(allowedOrigins), + localizeCallback() {}, + }); + + let contentScript = new WebExtensionContentScript(policy, { + checkPermissions, + + 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)), + }); + + equal( + expectMatches, + contentScript.matchesURI(newURI("http://www.foo.com/bar")), + `Simple matches include should ${expectMatches ? "" : "not "} match.` + ); + + equal( + expectMatches, + contentScript.matchesURI(newURI("https://bar.com/baz/xflergx")), + `Simple matches include should ${expectMatches ? "" : "not "} 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" + ); +} + +add_task(function test_WebExtensionContentScript_urls_mv2() { + return test_url_matching({ manifestVersion: 2, expectMatches: true }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv2_checkPermissions() { + return test_url_matching({ + manifestVersion: 2, + checkPermissions: true, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv2_with_permissions() { + return test_url_matching({ + manifestVersion: 2, + checkPermissions: true, + allowedOrigins: ["<all_urls>"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_urls_mv3() { + // checkPermissions ignored here because it's forced for MV3. + return test_url_matching({ + manifestVersion: 3, + checkPermissions: false, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_all_urls() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: ["<all_urls>"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_wildcards() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: ["*://*.foo.com/*", "*://*.bar.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_mv3_specific() { + return test_url_matching({ + manifestVersion: 3, + allowedOrigins: ["http://www.foo.com/*", "https://bar.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_restricted() { + let tests = [ + { + manifestVersion: 2, + permissions: [], + expect: false, + }, + { + manifestVersion: 2, + permissions: ["mozillaAddons"], + expect: true, + }, + { + manifestVersion: 3, + permissions: [], + expect: false, + }, + { + manifestVersion: 3, + permissions: ["mozillaAddons"], + expect: true, + }, + ]; + + for (let { manifestVersion, permissions, expect } of tests) { + let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + manifestVersion, + permissions, + allowedOrigins: new MatchPatternSet(["<all_urls>"]), + localizeCallback() {}, + }); + let contentScript = new WebExtensionContentScript(policy, { + checkPermissions: true, + matches: new MatchPatternSet(["<all_urls>"]), + }); + + // AMO is on the extensions.webextensions.restrictedDomains list. + equal( + expect, + contentScript.matchesURI(newURI("https://addons.mozilla.org/foo")), + `Expect extension with [${permissions}] to ${expect ? "" : "not"} match` + ); + } +}); + +async function test_frame_matching(meta) { + 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 = await ExtensionTestUtils.loadContentPage(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, meta }, args => { + let { manifestVersion = 2, allowedOrigins = [], expectMatches } = args.meta; + + 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", + + manifestVersion, + allowedOrigins: new MatchPatternSet(allowedOrigins), + 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] && expectMatches, + `Script ${i} ${should} match the ${frame} frame` + ); + } + } + }); + + await contentPage.close(); +} + +add_task(function test_WebExtensionContentScript_frames_mv2() { + return test_frame_matching({ + manifestVersion: 2, + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3() { + return test_frame_matching({ + manifestVersion: 3, + expectMatches: false, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_all_urls() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: ["<all_urls>"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_wildcards() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: ["*://*.example.com/*"], + expectMatches: true, + }); +}); + +add_task(function test_WebExtensionContentScript_frames_mv3_specific() { + return test_frame_matching({ + manifestVersion: 3, + allowedOrigins: ["http://example.com/*"], + expectMatches: true, + }); +}); 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..ff2cc3c2ac --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js @@ -0,0 +1,620 @@ +/* -*- 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: [ + { + resources: ["/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.isWebAccessiblePath("/foo/bar"), + "Web-accessible glob should be web-accessible" + ); + ok( + policy.isWebAccessiblePath("/bar.baz"), + "Web-accessible path should be web-accessible" + ); + ok( + !policy.isWebAccessiblePath("/bar.baz/quux"), + "Non-web-accessible path should not be web-accessible" + ); + + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Web-accessible path should be web-accessible to self" + ); + + // 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; + } +}); + +// mozExtensionHostname is normalized to lower case when using +// policy.getURL whereas using policy.getByHostname does +// not. Tests below will fail without case insensitive +// comparisons in ExtensionPolicyService +add_task(async function test_WebExtensionPolicy_case_sensitivity() { + const id = "policy-case@mochitest"; + const uuid = "BAD93A23-125C-4B24-ABFC-1CA2692B0610"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id: id, + mozExtensionHostname: uuid, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + policy.active = true; + + equal( + WebExtensionPolicy.getByHostname(uuid)?.mozExtensionHostname, + policy.mozExtensionHostname, + "Hostname lookup should match policy" + ); + + equal( + WebExtensionPolicy.getByHostname(uuid.toLowerCase())?.mozExtensionHostname, + policy.mozExtensionHostname, + "Hostname lookup should match policy" + ); + + equal(policy.getURL(), mozExtURI.spec, "Urls should match policy"); + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Extension path should be accessible to self" + ); + + policy.active = false; +}); + +add_task(async function test_WebExtensionPolicy_V3() { + const id = "foo@bar.baz"; + const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610"; + const id2 = "foo-2@bar.baz"; + const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd"; + const id3 = "foo-3@bar.baz"; + const uuid3 = "56652231-D7E2-45D1-BDBD-BD3BFF80927E"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + const fooSite = newURI("http://foo.bar/"); + const exampleSite = newURI("https://example.com/"); + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + manifestVersion: 3, + + localizeCallback(str) { + return `<${str}>`; + }, + + allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], { + ignorePath: true, + }), + permissions: ["<all_urls>"], + webAccessibleResources: [ + { + resources: ["/foo/*", "/bar.baz"].map(glob => new MatchGlob(glob)), + matches: ["http://foo.bar/"], + extension_ids: [id3], + }, + { + resources: ["/foo.bar.baz"].map(glob => new MatchGlob(glob)), + extension_ids: ["*"], + }, + ], + }); + policy.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid), + policy, + "Hostname lookup should match policy" + ); + + let policy2 = new WebExtensionPolicy({ + id: id2, + mozExtensionHostname: uuid2, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + policy2.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid2), + policy2, + "Hostname lookup should match policy" + ); + + let policy3 = new WebExtensionPolicy({ + id: id3, + mozExtensionHostname: uuid3, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + policy3.active = true; + equal( + WebExtensionPolicy.getByHostname(uuid3), + policy3, + "Hostname lookup should match policy" + ); + + ok( + policy.isWebAccessiblePath("/bar.baz"), + "Web-accessible path should be web-accessible" + ); + ok( + !policy.isWebAccessiblePath("/bar.baz/quux"), + "Non-web-accessible path should not be web-accessible" + ); + // Extension can always access itself + ok( + policy.sourceMayAccessPath(mozExtURI, "/bar.baz"), + "Web-accessible path should be accessible to self" + ); + ok( + policy.sourceMayAccessPath(mozExtURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to self" + ); + + ok( + !policy.sourceMayAccessPath(newURI(`https://${uuid}/`), "/bar.baz"), + "Web-accessible path should not be accessible due to scheme mismatch" + ); + + // non-matching site cannot access url + ok( + policy.sourceMayAccessPath(fooSite, "/bar.baz"), + "Web-accessible path should be accessible to foo.bar site" + ); + ok( + !policy.sourceMayAccessPath(fooSite, "/foo.bar.baz"), + "Web-accessible path should not be accessible to foo.bar site" + ); + + // non-matching site cannot access url + ok( + !policy.sourceMayAccessPath(exampleSite, "/bar.baz"), + "Web-accessible path should not be accessible to example.com" + ); + ok( + !policy.sourceMayAccessPath(exampleSite, "/foo.bar.baz"), + "Web-accessible path should not be accessible to example.com" + ); + + let extURI = newURI(policy2.getURL("")); + ok( + !policy.sourceMayAccessPath(extURI, "/bar.baz"), + "Web-accessible path should not be accessible to other extension" + ); + ok( + policy.sourceMayAccessPath(extURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + + extURI = newURI(policy3.getURL("")); + ok( + policy.sourceMayAccessPath(extURI, "/bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + ok( + policy.sourceMayAccessPath(extURI, "/foo.bar.baz"), + "Web-accessible path should be accessible to other extension" + ); + + policy.active = false; + policy2.active = false; + policy3.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" + ); +}); + +add_task(async function test_WebExtensionPolicy_static_themes_resources() { + const uuid = "0e7ae607-b5b3-4204-9838-c2138c14bc3c"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id: "test-extension@mochitest", + mozExtensionHostname: uuid, + baseURL: "file:///foo/foo/", + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: [], + }); + policy.active = true; + + let staticThemePolicy = new WebExtensionPolicy({ + id: "statictheme@bar.baz", + mozExtensionHostname: "164d05dc-b45b-4731-aefc-7c1691bae9a4", + baseURL: "file:///static_theme/", + type: "theme", + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + }); + + staticThemePolicy.active = true; + + ok( + staticThemePolicy.sourceMayAccessPath(mozExtURI, "/someresource.ext"), + "Active extensions should be allowed to access the static themes resources" + ); + + policy.active = false; + + ok( + !staticThemePolicy.sourceMayAccessPath(mozExtURI, "/someresource.ext"), + "Disabled extensions should be disallowed the static themes resources" + ); + + ok( + !staticThemePolicy.sourceMayAccessPath( + Services.io.newURI("http://example.com"), + "/someresource.ext" + ), + "Web content should be disallowed the static themes resources" + ); + + staticThemePolicy.active = false; +}); 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..c860d73cc8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js @@ -0,0 +1,303 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +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"; + + 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.defaultCSPV3, + }, + { + 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'; 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: 2, + content_security_policy: `script-src 'self' https://example.com`, + }, + expectedPolicy: `script-src 'self' https://example.com`, + }, + { + name: "manifest_v2 allows unsafe-eval", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self' 'unsafe-eval'`, + }, + expectedPolicy: `script-src 'self' 'unsafe-eval'`, + }, + { + name: "manifest_v2 allows wasm-unsafe-eval", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + expectedPolicy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + { + // object-src used to require local sources, but now we accept anything. + name: "manifest_v2 allows object-src, with non-local sources", + manifest: { + manifest_version: 2, + content_security_policy: `script-src 'self'; object-src https:'`, + }, + expectedPolicy: `script-src 'self'; object-src https:'`, + }, + { + name: "manifest_v3 invalid csp results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'none'`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 forbidden protocol results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://*`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 forbidden eval results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'unsafe-eval'`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 disallows localhost", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://localhost`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 disallows 127.0.0.1", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://127.0.0.1`, + }, + }, + expectedPolicy: aps.defaultCSPV3, + }, + { + name: "manifest_v3 allows wasm-unsafe-eval", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'wasm-unsafe-eval'`, + }, + }, + expectedPolicy: `script-src 'self' 'wasm-unsafe-eval'`, + }, + { + // object-src used to require local sources, but now we accept anything. + name: "manifest_v3 allows object-src, with non-local sources", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self'; object-src https:'`, + }, + }, + expectedPolicy: `script-src 'self'; object-src https:'`, + }, + { + 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.defaultCSPV3, + }, + { + 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..12ba3f93e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js @@ -0,0 +1,322 @@ +/* -*- 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' 'wasm-unsafe-eval'", + 0, + "\u2018script-src\u2019 directive contains a forbidden 'wasm-unsafe-eval' keyword", + "wasm disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'", + flags.CSP_ALLOW_WASM, + null, + "wasm allowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval' 'wasm-unsafe-eval'", + flags.CSP_ALLOW_EVAL, + null, + "wasm and 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';", null); + + // In the past, object-src was required to be secure and defaulted to 'self'. + // But that is no longer required (see bug 1766881). + checkPolicy("script-src 'self'; object-src 'self';", null); + checkPolicy("script-src 'self'; object-src https:;", 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' http:", + "Policy is missing a required \u2018script-src\u2019 directive", + "A strict default-src is required as a fallback if script-src is missing" + ); + + checkPolicy( + "default-src 'self' http:; script-src 'self'", + null, + "A valid script-src removes the need for a strict default-src fallback" + ); + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src" + ); + + checkPolicy( + "default-src 'self'; script-src 'self'", + null, + "A valid default-src should count as a valid script-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( + "script-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy( + "script-src 'self' 'unsafe-inline'", + "\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};`, null); + } + + let directives = ["script-src", "worker-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}`); + + // While Schemas.jsm uses Ci.nsIAddonContentPolicy.CSP_ALLOW_WASM, we don't + // pass that here because we are only verifying that remote scripts are + // blocked here. + let result = cps.validateAddonCSP(policy, 0); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self';", null); + checkPolicy("script-src 'self'; worker-src 'none'", null); + checkPolicy("script-src 'self'; worker-src 'self'", null); + + // In the past, object-src was required to be secure and defaulted to 'self'. + // But that is no longer required (see bug 1766881). + checkPolicy("script-src 'self'; object-src 'self';", null); + checkPolicy("script-src 'self'; object-src https:;", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}; `, + null + ); + + for (let policy of ["", "script-src-elem 'none';", "worker-src 'none';"]) { + checkPolicy( + policy, + "Policy is missing a required \u2018script-src\u2019 directive" + ); + } + + checkPolicy( + "default-src 'self' http:; script-src 'self'", + null, + "A valid script-src removes the need for a strict default-src fallback" + ); + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src" + ); + + for (let directive of ["script-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 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy( + "script-src 'self' 'unsafe-inline';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + checkPolicy( + "script-src 'self' 'unsafe-eval';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword" + ); + + // Localhost is invalid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + const protocol = src.split(":")[0]; + checkPolicy( + `script-src 'self' ${src};`, + `\u2018script-src\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + let directives = ["script-src", "worker-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..07b688b406 --- /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.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +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..c60b24b2b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js @@ -0,0 +1,77 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = false; +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +// This test should produce a warning, but still startup +add_task(async function test_api_restricted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "activityLog-permission@tests.mozilla.org" }, + }, + permissions: ["activityLog"], + }, + async background() { + browser.test.assertEq( + undefined, + browser.activityLog, + "activityLog is privileged" + ); + }, + useAddonManager: "permanent", + }); + await extension.startup(); + await extension.unload(); +}); + +// This test should produce a error and not startup +add_task( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + async function test_api_restricted_temporary_without_privilege() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + isPrivileged: false, + manifest: { + browser_specific_settings: { + gecko: { id: "activityLog-permission@tests.mozilla.org" }, + }, + permissions: ["activityLog"], + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: /Using the privileged permission 'activityLog' requires a privileged add-on/, + }, + ], + }, + true + ); + } +); 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..2d8b02bcd9 --- /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), + /can't access private field or method/, + "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), + /can't access private field or method/, + "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), + /can't access private field or method/, + "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), + /can't access private field or method/, + "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), + /can't access private field or method/, + "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), + /can't access private field or method/, + "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..892a82e2e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js @@ -0,0 +1,346 @@ +/* -*- 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"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task( + { + // TODO(Bug 1725478): remove the skip if once webidl API bindings will be hidden based on permissions. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + 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(); +}); + +// This test case covers the behavior of browser.alarms.create when the +// first optional argument (the alarm name) is passed explicitly as null +// or undefined instead of being omitted. +add_task(async function test_alarm_name_arg_null_or_undefined() { + async function backgroundScript(alarmName) { + browser.alarms.create(alarmName, { when: Date.now() + 2000000 }); + + let alarm = await browser.alarms.get(); + browser.test.assertTrue(alarm, "got an alarm"); + 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-test-done"); + } + + for (const alarmName of [null, undefined]) { + info(`Test alarm.create with alarm name ${alarmName}`); + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})(${alarmName})`, + manifest: { + permissions: ["alarms"], + }, + }); + await extension.startup(); + await extension.awaitFinish("alarm-test-done"); + 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(); +}); + +function getAlarmExtension(alarmCreateOptions, extOpts = {}) { + 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.sendMessage("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.sendMessage("alarms-create-with-options"); + }, 10000); + } + + let { persistent, useAddonManager } = extOpts; + return ExtensionTestUtils.loadExtension({ + useAddonManager, + // Pass the alarms.create options to the background page. + background: `(${backgroundScript})(${JSON.stringify(alarmCreateOptions)})`, + manifest: { + permissions: ["alarms"], + background: { persistent }, + }, + }); +} + +async function test_alarm_fires_with_options(alarmCreateOptions) { + let extension = getAlarmExtension(alarmCreateOptions); + + await extension.startup(); + await extension.awaitMessage("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" + ); +}); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +add_task( + { + // TODO(Bug 1748665): remove the skip once background service worker is also + // woken up by persistent listeners. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + pref_set: [ + ["privacy.resistFingerprinting.reduceTimerPrecision.jitter", false], + ["extensions.eventPages.enabled", true], + ], + }, + async function test_alarm_persists() { + await AddonTestUtils.promiseStartupManager(); + + let extension = getAlarmExtension( + { periodInMinutes: 0.01 }, + { useAddonManager: "permanent", persistent: false } + ); + info(`wait startup`); + await extension.startup(); + assertPersistentListeners(extension, "alarms", "onAlarm", { + primed: false, + }); + info(`wait first alarm`); + await extension.awaitMessage("alarms-create-with-options"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "alarms", "onAlarm", { + primed: true, + }); + + // Test an early startup event + let events = trackEvents(extension); + ok( + !events.get("background-script-event"), + "Should not have received a background script event" + ); + ok( + !events.get("start-background-script"), + "Background script should not be started" + ); + + info("waiting for alarm to wake background"); + await extension.awaitMessage("alarms-create-with-options"); + ok( + events.get("background-script-event"), + "Should have received a background script event" + ); + ok( + events.get("start-background-script"), + "Background script should be started" + ); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); 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..2a13a295a9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js @@ -0,0 +1,75 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" +); +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_asyncAPICall_isHandlingUserInput.js b/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js new file mode 100644 index 0000000000..6c0ccd860d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_asyncAPICall_isHandlingUserInput.js @@ -0,0 +1,149 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +const API_CLASS = class extends ExtensionAPI { + getAPI(context) { + return { + testMockAPI: { + async anAsyncAPIMethod(...args) { + const callContextDataBeforeAwait = context.callContextData; + await Promise.resolve(); + const callContextDataAfterAwait = context.callContextData; + return { + args, + callContextDataBeforeAwait, + callContextDataAfterAwait, + }; + }, + }, + }; + } +}; + +const API_SCRIPT = ` + this.testMockAPI = ${API_CLASS.toString()}; +`; + +const API_SCHEMA = [ + { + namespace: "testMockAPI", + functions: [ + { + name: "anAsyncAPIMethod", + type: "function", + async: true, + parameters: [ + { + name: "param1", + type: "object", + additionalProperties: { + type: "string", + }, + }, + { + name: "param2", + type: "string", + }, + ], + }, + ], + }, +]; + +const MODULE_INFO = { + testMockAPI: { + schema: `data:,${JSON.stringify(API_SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["testMockAPI"]], + url: URL.createObjectURL(new Blob([API_SCRIPT])), + }, +}; + +add_setup(async function() { + // The blob:-URL registered above in MODULE_INFO gets loaded at + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); + }); + + ExtensionParent.apiManager.registerModules(MODULE_INFO); +}); + +add_task( + async function test_propagated_isHandlingUserInput_on_async_api_methods_calls() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "@test-ext" } }, + }, + background() { + browser.test.onMessage.addListener(async (msg, args) => { + if (msg !== "async-method-call") { + browser.test.fail(`Unexpected test message: ${msg}`); + return; + } + + try { + let result = await browser.testMockAPI.anAsyncAPIMethod(...args); + browser.test.sendMessage("async-method-call:result", result); + } catch (err) { + browser.test.sendMessage("async-method-call:error", err.message); + } + }); + }, + }); + + await extension.startup(); + + const callArgs = [{ param1: "param1" }, "param2"]; + + info("Test API method called without handling user input"); + + extension.sendMessage("async-method-call", callArgs); + const result = await extension.awaitMessage("async-method-call:result"); + Assert.deepEqual( + result?.args, + callArgs, + "Got the expected parameters when called without handling user input" + ); + Assert.deepEqual( + result?.callContextDataBeforeAwait, + { isHandlingUserInput: false }, + "Got the expected callContextData before awaiting on a promise" + ); + Assert.deepEqual( + result?.callContextDataAfterAwait, + null, + "context.callContextData should have been nullified after awaiting on a promise" + ); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("async-method-call", callArgs); + const result = await extension.awaitMessage("async-method-call:result"); + Assert.deepEqual( + result?.args, + callArgs, + "Got the expected parameters when called while handling user input" + ); + Assert.deepEqual( + result?.callContextDataBeforeAwait, + { isHandlingUserInput: true }, + "Got the expected callContextData before awaiting on a promise" + ); + Assert.deepEqual( + result?.callContextDataAfterAwait, + null, + "context.callContextData should have been nullified after awaiting on a promise" + ); + }); + + 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..61157cba52 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js @@ -0,0 +1,190 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { BrowserTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/BrowserTestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" +); + +// 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"); + await promiseRestartManager({ earlyStartup: false }); + 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"); + AddonTestUtils.notifyLateStartup(); + + // This is the expected message from the re-enabled add-on. + await extension.awaitMessage("background_startup_observed"); + await extension.unload(); + + await promiseShutdownManager(); + + 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"); + + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + + let bgStartupPromise = new Promise(resolve => { + function onBackgroundPageDone(eventName) { + extension.extension.off( + "background-script-started", + onBackgroundPageDone + ); + extension.extension.off( + "background-script-aborted", + onBackgroundPageDone + ); + + if (eventName === "background-script-aborted") { + info("Background script startup was interrupted"); + resolve("bg_aborted"); + } else { + info("Background script startup finished normally"); + resolve("bg_fully_loaded"); + } + } + extension.extension.on("background-script-started", onBackgroundPageDone); + extension.extension.on("background-script-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. + AddonTestUtils.notifyLateStartup(); + 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"); +}); 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..19a918eff9 --- /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.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +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..9ce80f3fda --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js @@ -0,0 +1,44 @@ +/* -*- 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("browser.privatebrowsing.autostart", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); + }); + + 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_service_worker.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js new file mode 100644 index 0000000000..cc1b0dd054 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_service_worker.js @@ -0,0 +1,323 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); +AddonTestUtils.overrideCertDB(); + +add_task(async function setup() { + ok( + WebExtensionPolicy.useRemoteWebExtensions, + "Expect remote-webextensions mode enabled" + ); + ok( + WebExtensionPolicy.backgroundServiceWorkerEnabled, + "Expect remote-webextensions mode enabled" + ); + + await AddonTestUtils.promiseStartupManager(); + + Services.prefs.setBoolPref("dom.serviceWorkers.testing.enabled", true); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("dom.serviceWorkers.testing.enabled"); + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + }); +}); + +add_task( + async function test_fail_spawn_extension_worker_for_disabled_extension() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": "dump('Background ServiceWorker - executed\\n');", + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher(); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker( + extension + ); + + await extension.startup(); + + info("Wait for the background service worker to be spawned"); + + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + const swReg = testWorkerWatcher.getRegistration(extension); + ok(swReg, "Got a service worker registration"); + ok(swReg?.activeWorker, "Got an active worker"); + + info("Spawn the active worker by attaching the debugger"); + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + swReg.activeWorker.attachDebugger(); + info( + "Wait for the background service worker to be spawned after attaching the debugger" + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + swReg.activeWorker.detachDebugger(); + info( + "Wait for the background service worker to be terminated after detaching the debugger" + ); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + info( + "Disabling the addon policy, and then double-check that the worker can't be spawned" + ); + const policy = WebExtensionPolicy.getByID(extension.id); + policy.active = false; + + await Assert.throws( + () => swReg.activeWorker.attachDebugger(), + /InvalidStateError/, + "Got the expected extension when trying to spawn a worker for a disabled addon" + ); + + info( + "Enabling the addon policy and double-check the worker is spawned successfully" + ); + policy.active = true; + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + swReg.activeWorker.attachDebugger(); + info( + "Wait for the background service worker to be spawned after attaching the debugger" + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + swReg.activeWorker.detachDebugger(); + info( + "Wait for the background service worker to be terminated after detaching the debugger" + ); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + await testWorkerWatcher.destroy(); + await extension.unload(); + } +); + +add_task(async function test_serviceworker_lifecycle_events() { + async function assertLifecycleEvents({ extension, expected, message }) { + const getLifecycleEvents = async () => { + const { active } = await this.content.navigator.serviceWorker.ready; + const { port1, port2 } = new content.MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = msg => resolve(msg.data.lifecycleEvents); + active.postMessage("test", [port2]); + }); + }; + const page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html"), + { extension } + ); + Assert.deepEqual( + await page.spawn([], getLifecycleEvents), + expected, + `Got the expected lifecycle events on ${message}` + ); + await page.close(); + } + + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": ` + dump('Background ServiceWorker - executed\\n'); + + const lifecycleEvents = []; + self.oninstall = () => { + dump('Background ServiceWorker - oninstall\\n'); + lifecycleEvents.push("install"); + }; + self.onactivate = () => { + dump('Background ServiceWorker - onactivate\\n'); + lifecycleEvents.push("activate"); + }; + self.onmessage = (evt) => { + dump('Background ServiceWorker - onmessage\\n'); + evt.ports[0].postMessage({ lifecycleEvents }); + dump('Background ServiceWorker - postMessage\\n'); + }; + `, + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher(); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + await extension.startup(); + + await assertLifecycleEvents({ + extension, + expected: ["install", "activate"], + message: "initial worker registration", + }); + + const file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("serviceworker.txt"); + await TestUtils.waitForCondition( + () => file.exists(), + "Wait for service worker registrations to have been dumped on disk" + ); + + const managerShutdownCompleted = AddonTestUtils.promiseShutdownManager(); + + const firstSwReg = swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + // Force the worker shutdown (in normal condition the worker would have been + // terminated as part of the entire application shutting down). + firstSwReg.forceShutdown(); + + info( + "Wait for the background service worker to be terminated while the app is shutting down" + ); + ok( + await watcher.promiseWorkerTerminated, + "The extension service worker has been terminated as expected" + ); + await managerShutdownCompleted; + + Assert.equal( + firstSwReg, + swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ), + "Expect the service worker to not be unregistered on application shutdown" + ); + + info("Restart AddonManager (mocking Browser instance restart)"); + // Start the addon manager with `earlyStartup: false` to keep the background service worker + // from being started right away: + // + // - the call to `swm.reloadRegistrationForTest()` that follows is making sure that + // the previously registered service worker is in the same state it would be when + // the entire browser is restarted. + // + // - if the background service worker is being spawned again by the time we call + // `swm.reloadRegistrationForTest()`, ServiceWorkerUpdateJob would fail and trigger + // an `mState == State::Started` diagnostic assertion from ServiceWorkerJob::Finish + // and the xpcshell test will fail for the crash triggered by the assertion. + await AddonTestUtils.promiseStartupManager({ lateStartup: false }); + await extension.awaitStartup(); + + info( + "Force reload ServiceWorkerManager registrations (mocking a Browser instance restart)" + ); + swm.reloadRegistrationsForTest(); + + info( + "trigger delayed call to nsIServiceWorkerManager.registerForAddonPrincipal" + ); + // complete the startup notifications, then start the background + AddonTestUtils.notifyLateStartup(); + extension.extension.emit("start-background-script"); + + info("Force activate the extension worker"); + const newSwReg = swm.getRegistrationByPrincipal( + extension.extension.principal, + extension.extension.principal.spec + ); + + Assert.notEqual( + newSwReg, + firstSwReg, + "Expect the service worker registration to have been recreated" + ); + + await assertLifecycleEvents({ + extension, + expected: [], + message: "on previous registration loaded", + }); + + const { principal } = extension.extension; + const addon = await AddonManager.getAddonByID(extension.id); + await addon.disable(); + + ok( + await watcher.promiseWorkerTerminated, + "The extension service worker has been terminated as expected" + ); + + Assert.throws( + () => swm.getRegistrationByPrincipal(principal, principal.spec), + /NS_ERROR_FAILURE/, + "Expect the service worker to have been unregistered on addon disabled" + ); + + await addon.enable(); + await assertLifecycleEvents({ + extension, + expected: ["install", "activate"], + message: "on disabled addon re-enabled", + }); + + await testWorkerWatcher.destroy(); + 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..a44431682f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js @@ -0,0 +1,98 @@ +"use strict"; + +add_task(async function test_background_reload_and_unload() { + let events = []; + { + const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" + ); + 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..61c022ffc4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js @@ -0,0 +1,99 @@ +/* -*- 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() { + 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..774a9d1dc5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js @@ -0,0 +1,536 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +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, + "layout.css.prefers-color-scheme.content-override": 2, + "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; + let apiNameSplit = apiName.split("."); + for (let apiPart of apiNameSplit) { + apiObj = apiObj[apiPart]; + } + if (msg == "get") { + browser.test.sendMessage("settingData", await apiObj.get({})); + return; + } + + // set and setNoOp + + // 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, + }); + } + + extension.sendMessage("get", "ftpProtocolEnabled"); + let data = await extension.awaitMessage("settingData"); + equal(data.value, false); + equal( + data.levelOfControl, + "not_controllable", + `ftpProtocolEnabled is not controllable.` + ); + + 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("overrideContentColorScheme", "dark", { + "layout.css.prefers-color-scheme.content-override": 0, + }); + await testSetting("overrideContentColorScheme", "light", { + "layout.css.prefers-color-scheme.content-override": 1, + }); + await testSetting("overrideContentColorScheme", "auto", { + "layout.css.prefers-color-scheme.content-override": 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 testSetting("colorManagement.mode", "off", { + "gfx.color_management.mode": 0, + }); + await testSetting("colorManagement.mode", "full", { + "gfx.color_management.mode": 1, + }); + await testSetting("colorManagement.mode", "tagged_only", { + "gfx.color_management.mode": 2, + }); + + await testSetting("colorManagement.useNativeSRGB", false, { + "gfx.color_management.native_srgb": false, + }); + await testSetting("colorManagement.useNativeSRGB", true, { + "gfx.color_management.native_srgb": true, + }); + + await testSetting("colorManagement.useWebRenderCompositor", false, { + "gfx.webrender.compositor": false, + }); + await testSetting("colorManagement.useWebRenderCompositor", true, { + "gfx.webrender.compositor": true, + }); + + 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." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideContentColorScheme.set({ value: 0 }), + /0 is not a valid value for overrideContentColorScheme/, + "overrideContentColorScheme.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideContentColorScheme.set({ value: "bad" }), + /bad is not a valid value for overrideContentColorScheme/, + "overrideContentColorScheme.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomFullPage.set({ value: 0 }), + /0 is not a valid value for zoomFullPage/, + "zoomFullPage.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomFullPage.set({ value: "bad" }), + /bad is not a valid value for zoomFullPage/, + "zoomFullPage.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomSiteSpecific.set({ value: 0 }), + /0 is not a valid value for zoomSiteSpecific/, + "zoomSiteSpecific.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.zoomSiteSpecific.set({ value: "bad" }), + /bad is not a valid value for zoomSiteSpecific/, + "zoomSiteSpecific.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", + browser_specific_settings: { + 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", + browser_specific_settings: { + 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_cache_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js new file mode 100644 index 0000000000..277a69271d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cache_api.js @@ -0,0 +1,303 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const server = createHttpServer({ + hosts: ["example.com", "anotherdomain.com"], +}); +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("test_ext_cache_api.js"); +}); + +add_task(async function test_cache_api_http_resource_allowed() { + async function background() { + try { + const BASE_URL = `http://example.com/dummy`; + + const 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 http urls + // works as well. + await cache.add(BASE_URL); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL).then(res => res.text()), + "Got the expected content from the cached http url" + ); + + // Test that the http urls that the cache API is allowed + // to fetch and cache are limited by the host permissions + // associated to the extensions (same as when the extension + // for fetch from those urls using fetch or XHR). + await browser.test.assertRejects( + cache.add(`http://anotherdomain.com/dummy`), + "NetworkError when attempting to fetch resource.", + "Got the expected rejection of requesting an http not allowed by host permissions" + ); + + // 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"); + } + } + + // Verify that Cache API support for http urls is available + // regardless of extensions.backgroundServiceWorker.enabled pref. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["http://example.com/*"] }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("test-cache-api-allowed"); + await extension.unload(); +}); + +// This test is similar to `test_cache_api_http_resource_allowed` but it does +// exercise the Cache API from a moz-extension shared worker. +// We expect the cache API calls to be successfull when it is being used to +// cache an HTTP url that is allowed for the extensions based on its host +// permission, but to fail if the extension doesn't have the required host +// permission to fetch data from that url. +add_task(async function test_cache_api_from_ext_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 BASE_URL_OK = `http://example.com/dummy`; + const BASE_URL_KO = `http://anotherdomain.com/dummy`; + const worker = new SharedWorker("worker.js"); + const { data: resultOK } = await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage(["worker-cacheapi-test-allowed", BASE_URL_OK]); + }); + browser.test.log( + `Got result from extension worker for allowed host url: ${JSON.stringify( + resultOK + )}` + ); + const { data: resultKO } = await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage(["worker-cacheapi-test-disallowed", BASE_URL_KO]); + }); + browser.test.log( + `Got result from extension worker for disallowed host url: ${JSON.stringify( + resultKO + )}` + ); + + browser.test.assertTrue( + await window.caches.has("test-cache-api"), + "CacheStorage.has should resolve to true" + ); + const cache = await window.caches.open("test-cache-api"); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL_OK).then(res => res.text()), + "Got the expected content from the cached http url" + ); + browser.test.assertEq( + true, + await cache.match(BASE_URL_KO).then(res => res == undefined), + "Got no match for the http url that isn't allowed by host permissions" + ); + + browser.test.sendMessage("test-cacheapi-sharedworker:done", { + expectAllowed: resultOK, + expectDisallowed: resultKO, + }); + }; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { permissions: ["http://example.com/*"] }, + files: { + "worker.js": function() { + self.onconnect = evt => { + const port = evt.ports[0]; + port.onmessage = async evt => { + let result = {}; + let message; + try { + const [msg, BASE_URL] = evt.data; + message = msg; + const cache = await self.caches.open("test-cache-api"); + await cache.add(BASE_URL); + result.success = true; + } catch (err) { + result.error = err.message; + throw err; + } finally { + port.postMessage([`${message}:result`, result]); + } + }; + }; + }, + }, + }); + + await extension.startup(); + const { expectAllowed, expectDisallowed } = await extension.awaitMessage( + "test-cacheapi-sharedworker:done" + ); + // Increase the chance to have the error message related to an unexpected + // failure to be explicitly mention in the failure message. + Assert.deepEqual( + expectAllowed, + ["worker-cacheapi-test-allowed:result", { success: true }], + "Expect worker result to be successfull with the required host permission" + ); + Assert.deepEqual( + expectDisallowed, + [ + "worker-cacheapi-test-disallowed:result", + { error: "NetworkError when attempting to fetch resource." }, + ], + "Expect worker result to be unsuccessfull without the required host permission" + ); + + await extension.unload(); +}); + +add_task(async function test_cache_storage_evicted_on_addon_uninstalled() { + async function background() { + try { + const BASE_URL = `http://example.com/dummy`; + + const 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 http urls + // works as well. + await cache.add(BASE_URL); + browser.test.assertEq( + "test_ext_cache_api.js", + await cache.match(BASE_URL).then(res => res.text()), + "Got the expected content from the cached http url" + ); + } catch (err) { + browser.test.fail(`Unexpected error on using Cache API: ${err}`); + throw err; + } finally { + browser.test.sendMessage("cache-storage-created"); + } + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["http://example.com/*"] }, + background, + // Necessary to be sure the expected extension stored data cleanup callback + // will be called when the extension is uninstalled from an AddonManager + // perspective. + useAddonManager: "temporary", + }); + + await AddonTestUtils.promiseStartupManager(); + await extension.startup(); + await extension.awaitMessage("cache-storage-created"); + + const extURL = `moz-extension://${extension.extension.uuid}`; + const extPrincipal = Services.scriptSecurityManager.createContentPrincipal( + Services.io.newURI(extURL), + {} + ); + let extCacheStorage = new CacheStorage("content", extPrincipal); + + ok( + await extCacheStorage.has("test-cache-api"), + "Got the expected extension cache storage" + ); + + await extension.unload(); + + ok( + !(await extCacheStorage.has("test-cache-api")), + "The extension cache storage data should have been evicted on addon uninstall" + ); +}); + +add_task( + { + // Pref used to allow to use the Cache WebAPI related to a page loaded from http + // (otherwise Gecko will throw a SecurityError when trying to access the webpage + // cache storage from the content script, unless the webpage is loaded from https). + pref_set: [["dom.caches.testing.enabled", true]], + }, + async function test_cache_put_from_contentscript() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": async function() { + const cache = await caches.open("test-cachestorage"); + const request = "http://example.com"; + const response = await fetch(request); + await cache.put(request, response).catch(err => { + browser.test.sendMessage("cache-put-error", { + name: err.name, + message: err.message, + }); + }); + }, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage("http://example.com"); + const actualError = await extension.awaitMessage("cache-put-error"); + equal( + actualError.name, + "SecurityError", + "Got a security error from cache.put call as expected" + ); + ok( + /Disallowed on WebExtension ContentScript Request/.test( + actualError.message + ), + `Got the expected error message: ${actualError.message}` + ); + + await page.close(); + 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..dfb5c4c415 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js @@ -0,0 +1,202 @@ +"use strict"; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +/** + * 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 = + '<meta http-equiv="refresh" content="0;url=https://support.mozilla.org/kb/captive-portal"/>'; +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(async 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); + + Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_captivePortal_basic() { + let cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService( + Ci.nsICaptivePortalService + ); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["captivePortal"], + background: { persistent: false }, + }, + 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.captivePortal.canonicalURL.onChange.addListener(details => { + browser.test.sendMessage("url", details); + }); + + browser.test.onMessage.addListener(async msg => { + if (msg == "getstate") { + browser.test.sendMessage( + "getstate", + await browser.captivePortal.getState() + ); + } + }); + }, + }); + await extension.startup(); + + extension.sendMessage("getstate"); + let details = await extension.awaitMessage("getstate"); + equal(details, "unknown", "initial state"); + + // 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); + + 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"); + + assertPersistentListeners( + extension, + "captivePortal", + "onConnectivityAvailable", + { + primed: false, + } + ); + + assertPersistentListeners(extension, "captivePortal", "onStateChanged", { + primed: false, + }); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: false, + }); + + info("Test event page terminate/waken"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "captivePortal", "onStateChanged", { + primed: true, + }); + assertPersistentListeners( + extension, + "captivePortal", + "onConnectivityAvailable", + { + primed: true, + } + ); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: true, + }); + + info("REFRESH 2nd pass to other"); + cpResponse = "other"; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("state"); + equal(details.state, "locked_portal", "state in portal"); + + info("Test event page terminate/waken with settings"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + assertPersistentListeners(extension, "captivePortal", "captiveURL.onChange", { + primed: true, + }); + + Services.prefs.setStringPref( + "captivedetect.canonicalURL", + "http://example.com" + ); + let url = await extension.awaitMessage("url"); + equal( + url.value, + "http://example.com", + "The canonicalURL setting has the expected value." + ); + + 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_clear_cached_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js new file mode 100644 index 0000000000..715cc3c320 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_clear_cached_resources.js @@ -0,0 +1,417 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +Services.prefs.setBoolPref("extensions.blocklist.enabled", false); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +const BASE64_R_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P4z8DwHwAFAAH/F1FwBgAAAABJRU5ErkJggg=="; +const BASE64_G_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2Ng+M/wHwAEAQH/7yMK/gAAAABJRU5ErkJggg=="; +const BASE64_B_PIXEL = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2NgYPj/HwADAgH/eL9GtQAAAABJRU5ErkJggg=="; + +const toArrayBuffer = b64data => + Uint8Array.from(atob(b64data), c => c.charCodeAt(0)); +const IMAGE_RED = toArrayBuffer(BASE64_R_PIXEL).buffer; +const IMAGE_GREEN = toArrayBuffer(BASE64_G_PIXEL).buffer; +const IMAGE_BLUE = toArrayBuffer(BASE64_B_PIXEL).buffer; + +const RGB_RED = "rgb(255, 0, 0)"; +const RGB_GREEN = "rgb(0, 255, 0)"; +const RGB_BLUE = "rgb(0, 0, 255)"; + +const CSS_RED_BG = `body { background-color: ${RGB_RED}; }`; +const CSS_GREEN_BG = `body { background-color: ${RGB_GREEN}; }`; +const CSS_BLUE_BG = `body { background-color: ${RGB_BLUE}; }`; + +const ADDON_ID = "test-cached-resources@test"; + +const manifest = { + version: "1", + browser_specific_settings: { gecko: { id: ADDON_ID } }, +}; + +const files = { + "extpage.html": `<!DOCTYPE html> + <html> + <head> + <link rel="stylesheet" href="extpage.css"> + </head> + <body> + <img id="test-image" src="image.png"> + </body> + </html> + `, + "other_extpage.html": `<!DOCTYPE html> + <html> + <body> + </body> + </html> + `, + "extpage.css": CSS_RED_BG, + "image.png": IMAGE_RED, +}; + +const getBackgroundColor = () => { + return this.content.getComputedStyle(this.content.document.body) + .backgroundColor; +}; + +const hasCachedImage = imgUrl => { + const { document } = this.content; + + const imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(document); + + const imgCacheProps = imageCache.findEntryProperties( + Services.io.newURI(imgUrl), + document + ); + + // return true if the image was in the cache. + return !!imgCacheProps; +}; + +const getImageColor = () => { + const { document } = this.content; + const img = document.querySelector("img#test-image"); + const canvas = document.createElement("canvas"); + canvas.width = 1; + canvas.height = 1; + const ctx = canvas.getContext("2d"); + ctx.drawImage(img, 0, 0); // Draw without scaling. + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + if (a < 1) { + return `rgba(${r}, ${g}, ${b}, ${a})`; + } + return `rgb(${r}, ${g}, ${b})`; +}; + +async function assertBackgroundColor(page, color, message) { + equal( + await page.spawn([], getBackgroundColor), + color, + `Got the expected ${message}` + ); +} + +async function assertImageColor(page, color, message) { + equal(await page.spawn([], getImageColor), color, message); +} + +async function assertImageCached(page, imageUrl, message) { + ok(await page.spawn([imageUrl], hasCachedImage), message); +} + +// This test verifies that cached css are cleared across addon upgrades and downgrades +// for permanently installed addon (See Bug 1746841). +add_task(async function test_cached_resources_cleared_across_addon_updates() { + await AddonTestUtils.promiseStartupManager(); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const url = extension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (initial extension version)" + ); + await assertImageColor(page, RGB_RED, "image (initial extension version)"); + + info("Verify extension page css and image after addon upgrade"); + + await extension.upgrade({ + useAddonManager: "permanent", + manifest: { + ...manifest, + version: "2", + }, + files: { + ...files, + "extpage.css": CSS_GREEN_BG, + "image.png": IMAGE_GREEN, + }, + }); + equal( + extension.version, + "2", + "Got the expected version for the upgraded extension" + ); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_GREEN, + "background color (upgraded extension version)" + ); + await assertImageColor(page, RGB_GREEN, "image (upgraded extension version)"); + + info("Verify extension page css and image after addon downgrade"); + + await extension.upgrade({ + useAddonManager: "permanent", + manifest, + files, + }); + equal( + extension.version, + "1", + "Got the expected version for the downgraded extension" + ); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_RED, + "background color (downgraded extension version)" + ); + await assertImageColor( + page, + RGB_RED, + "image color (downgraded extension version)" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test verifies that cached css are cleared if we are installing a new +// extension and we did not clear the cache for a previous one with the same uuid +// when it was uninstalled (See Bug 1746841). +add_task(async function test_cached_resources_cleared_on_addon_install() { + // Make sure the test addon installed without an AddonManager addon wrapper + // and the ones installed right after that using the AddonManager will share + // the same uuid (and so also the same moz-extension resource urls). + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + registerCleanupFunction(() => Services.prefs.clearUserPref(LEAVE_UUID_PREF)); + + await AddonTestUtils.promiseStartupManager(); + + const nonAOMExtension = ExtensionTestUtils.loadExtension({ + manifest, + files: { + ...files, + // Override css with a different color from the one expected + // later in this test case. + "extpage.css": CSS_BLUE_BG, + "image.png": IMAGE_BLUE, + }, + }); + + await nonAOMExtension.startup(); + equal( + await AddonManager.getAddonByID(ADDON_ID), + null, + "No AOM addon wrapper found as expected" + ); + let url = nonAOMExtension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_BLUE, + "background color (addon installed without uninstall observer)" + ); + await assertImageColor( + page, + RGB_BLUE, + "image (addon uninstalled without clearing cache)" + ); + + // NOTE: unloading a test extension that does not have an AddonManager addon wrapper + // does not trigger the uninstall observer, and this is what this test needs to make + // sure that if the cached resources were not cleared on uninstall, then we will still + // clear it when a newly installed addon is installed even if the two extensions + // are sharing the same addon uuid (and so also the same moz-extension resource urls). + await nonAOMExtension.unload(); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_RED, + "background color (newly installed addon, same addon id)" + ); + await assertImageColor( + page, + RGB_RED, + "image (newly installed addon, same addon id)" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test verifies that reloading a temporarily installed addon after +// changing a css file cached in a previous run clears the previously +// cached css and uses the new one changed on disk (See Bug 1746841). +add_task( + async function test_cached_resources_cleared_on_temporary_addon_reload() { + await AddonTestUtils.promiseStartupManager(); + + const xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest, + files, + }); + + // This temporary directory is going to be removed from the + // cleanup function, but also make it unique as we do for the + // other temporary files (e.g. like getTemporaryFile as defined + // in XPInstall.jsm). + const random = Math.round(Math.random() * 36 ** 3).toString(36); + const tmpDirName = `xpcshelltest_unpacked_addons_${random}`; + let tmpExtPath = FileUtils.getDir("TmpD", [tmpDirName], true); + registerCleanupFunction(() => { + tmpExtPath.remove(true); + }); + + // Unpacking the xpi file into the temporary directory. + const extDir = await AddonTestUtils.manuallyInstall( + xpi, + tmpExtPath, + null, + /* unpacked */ true + ); + + let extension = ExtensionTestUtils.expectExtension(ADDON_ID); + await AddonManager.installTemporaryAddon(extDir); + await extension.awaitStartup(); + + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const url = extension.extension.baseURI.resolve("extpage.html"); + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (initial extension version)" + ); + await assertImageColor(page, RGB_RED, "image (initial extension version)"); + + info("Verify updated extension page css and image after addon reload"); + + const targetCSSFile = extDir.clone(); + targetCSSFile.append("extpage.css"); + ok( + targetCSSFile.exists(), + `Found the ${targetCSSFile.path} target file on disk` + ); + await IOUtils.writeUTF8(targetCSSFile.path, CSS_GREEN_BG); + + const targetPNGFile = extDir.clone(); + targetPNGFile.append("image.png"); + ok( + targetPNGFile.exists(), + `Found the ${targetPNGFile.path} target file on disk` + ); + await IOUtils.write(targetPNGFile.path, toArrayBuffer(BASE64_G_PIXEL)); + + const addon = await AddonManager.getAddonByID(ADDON_ID); + ok(addon, "Got an AddonWrapper for the test extension"); + await addon.reload(); + + await page.loadURL(url); + + await assertBackgroundColor( + page, + RGB_GREEN, + "background (updated files on disk)" + ); + await assertImageColor(page, RGB_GREEN, "image (updated files on disk)"); + + await page.close(); + await addon.uninstall(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +// This test verifies that cached images are not cleared between +// permanently installed addon reloads. +add_task(async function test_cached_image_kept_on_permanent_addon_restarts() { + await AddonTestUtils.promiseStartupManager(); + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest, + files, + }); + + await extension.startup(); + + equal( + extension.version, + "1", + "Got the expected version for the initial extension" + ); + + const imageUrl = extension.extension.baseURI.resolve("image.png"); + const url = extension.extension.baseURI.resolve("extpage.html"); + + let page = await ExtensionTestUtils.loadContentPage(url); + await assertBackgroundColor( + page, + RGB_RED, + "background color (first startup)" + ); + await assertImageColor(page, RGB_RED, "image (first startup)"); + await assertImageCached(page, imageUrl, "image cached (first startup)"); + + info("Reload the AddonManager to simulate browser restart"); + extension.setRestarting(); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await page.loadURL(extension.extension.baseURI.resolve("other_extpage.html")); + await assertImageCached( + page, + imageUrl, + "image still cached after AddonManager restart" + ); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); 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..9210d11838 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js @@ -0,0 +1,809 @@ +"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", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeIdle})()` }], + runAt: "document_idle", + cookieStoreId: "firefox-container-1", + }, + // 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. + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_idle.js" }], + runAt: "document_idle", + cookieStoreId: "firefox-container-1", + }, + ]; + + 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++; + if (completeCount == 2) { + 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 contentScripts.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, + originAttributesPatterns, + } = script; + + deepEqual( + { + allFrames, + cssPaths, + jsPaths, + matchAboutBlank, + runAt, + originAttributesPatterns, + }, + { + allFrames: true, + cssPaths: [`${baseExtURL}/content_style.css`], + jsPaths: [`${baseExtURL}/content_script.js`], + matchAboutBlank: true, + runAt: "document_start", + originAttributesPatterns: null, + }, + "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(); +}); + +add_task(async function test_contentscripts_register_cookieStoreId() { + async function background() { + let cookieStoreIdCSSArray = [ + { id: null, color: "rgb(123, 45, 67)" }, + { id: "firefox-private", color: "rgb(255,255,0)" }, + { id: "firefox-default", color: "red" }, + { id: "firefox-container-1", color: "green" }, + { id: "firefox-container-2", color: "blue" }, + { + id: ["firefox-container-3", "firefox-container-4"], + color: "rgb(100,100,0)", + }, + ]; + const matches = ["http://localhost/*/file_sample_registered_styles.html"]; + + for (let { id, color } of cookieStoreIdCSSArray) { + await browser.contentScripts.register({ + css: [ + { + code: `#registered-extension-text-style { + background-color: ${color}}`, + }, + ], + matches, + runAt: "document_start", + cookieStoreId: id, + }); + } + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "not_a_valid_cookieStoreId", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + + if (!navigator.userAgent.includes("Android")) { + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "firefox-container-999", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + } else { + // On Android, any firefox-container-... is treated as valid, so it doesn't + // result in an error. + // TODO bug 1743616: Fix implementation and remove this branch. + await browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "firefox-container-999", + }); + } + + await browser.test.assertRejects( + browser.contentScripts.register({ + css: [{ code: `body {}` }], + matches, + cookieStoreId: "", + }), + /Invalid cookieStoreId/, + "contentScripts.register with an invalid cookieStoreId" + ); + + browser.test.sendMessage("background_ready"); + } + + const extensionData = { + 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, + }, + }; + + const extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("background_ready"); + // Index 0 is the one from manifest.json. + let contentScriptMatchTests = [ + { + contentPageOptions: { userContextId: 5 }, + expectedStyles: "rgb(123, 45, 67)", + originAttributesPatternExpected: null, + contentScriptIndex: 1, + }, + { + contentPageOptions: { privateBrowsing: true }, + expectedStyles: "rgb(255, 255, 0)", + originAttributesPatternExpected: [ + { + privateBrowsingId: 1, + userContextId: 0, + }, + ], + contentScriptIndex: 2, + }, + { + contentPageOptions: { userContextId: 0 }, + expectedStyles: "rgb(255, 0, 0)", + originAttributesPatternExpected: [ + { + privateBrowsingId: 0, + userContextId: 0, + }, + ], + contentScriptIndex: 3, + }, + { + contentPageOptions: { userContextId: 1 }, + expectedStyles: "rgb(0, 128, 0)", + originAttributesPatternExpected: [{ userContextId: 1 }], + contentScriptIndex: 4, + }, + { + contentPageOptions: { userContextId: 2 }, + expectedStyles: "rgb(0, 0, 255)", + originAttributesPatternExpected: [{ userContextId: 2 }], + contentScriptIndex: 5, + }, + { + contentPageOptions: { userContextId: 3 }, + expectedStyles: "rgb(100, 100, 0)", + originAttributesPatternExpected: [ + { userContextId: 3 }, + { userContextId: 4 }, + ], + contentScriptIndex: 6, + }, + { + contentPageOptions: { userContextId: 4 }, + expectedStyles: "rgb(100, 100, 0)", + originAttributesPatternExpected: [ + { userContextId: 3 }, + { userContextId: 4 }, + ], + contentScriptIndex: 6, + }, + ]; + + const policy = WebExtensionPolicy.getByID(extension.id); + + for (const testCase of contentScriptMatchTests) { + const { + contentPageOptions, + expectedStyles, + originAttributesPatternExpected, + contentScriptIndex, + } = testCase; + const script = policy.contentScripts[contentScriptIndex]; + + deepEqual(script.originAttributesPatterns, originAttributesPatternExpected); + let contentPage = await ExtensionTestUtils.loadContentPage( + `about:blank`, + contentPageOptions + ); + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + let registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionBlobStyleBG, + expectedStyles, + `Expected styles applied on content page loaded with options + ${JSON.stringify(contentPageOptions)}` + ); + await contentPage.close(); + } + + 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..35350a1e8e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js @@ -0,0 +1,362 @@ +"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 = []; +// Keep in sync with extensions.webextensions.base-content-security-policy +baseCSP[2] = { + "script-src": [ + "'unsafe-eval'", + "'wasm-unsafe-eval'", + "'unsafe-inline'", + "blob:", + "filesystem:", + "http://localhost:*", + "http://127.0.0.1:*", + "https://*", + "moz-extension:", + "'self'", + ], +}; +// Keep in sync with extensions.webextensions.base-content-security-policy.v3 +baseCSP[3] = { + "script-src": ["'self'", "'wasm-unsafe-eval'"], +}; + +/** + * @typedef TestPolicyExpects + * @type {object} + * @param {boolean} workerEvalAllowed + * @param {boolean} workerImportScriptsAllowed + * @param {boolean} workerWasmAllowed + */ + +/** + * 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 {object} options + * @param {number} [options.manifest_version] + * @param {object} [options.customCSP] + * @param {TestPolicyExpects} options.expects + */ +async function testPolicy({ + manifest_version = 2, + customCSP = null, + expects = {}, +}) { + info( + `Enter tests for extension CSP with ${JSON.stringify({ + manifest_version, + customCSP, + })}` + ); + + let baseURL; + + let addonCSP = { + "script-src": ["'self'"], + }; + + if (manifest_version < 3) { + addonCSP["script-src"].push("'wasm-unsafe-eval'"); + } + + 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.runtime.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 = () => { + let importScriptsAllowed; + let evalAllowed; + let wasmAllowed; + + try { + eval("let y = true;"); // eslint-disable-line no-eval + evalAllowed = true; + } catch (e) { + evalAllowed = false; + } + + try { + new WebAssembly.Module( + new Uint8Array([0, 0x61, 0x73, 0x6d, 0x1, 0, 0, 0]) + ); + wasmAllowed = true; + } catch (e) { + wasmAllowed = false; + } + + try { + // eslint-disable-next-line no-undef + importScripts(`http://127.0.0.1:${port}/worker.js`); + importScriptsAllowed = true; + } catch (e) { + importScriptsAllowed = false; + } + + postMessage({ evalAllowed, importScriptsAllowed, wasmAllowed }); + }; + } + + let web_accessible_resources = ["content.html", "tab.html"]; + if (manifest_version == 3) { + let extension_pages = content_security_policy; + content_security_policy = { + extension_pages, + }; + let resources = web_accessible_resources; + web_accessible_resources = [ + { resources, matches: ["http://example.com/*"] }, + ]; + } + + 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, + }, + }); + + 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: ${JSON.stringify(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"); + equal( + workerCSP.importScriptsAllowed, + expects.workerImportAllowed, + "worker importScript" + ); + equal(workerCSP.evalAllowed, expects.workerEvalAllowed, "worker eval"); + equal(workerCSP.wasmAllowed, expects.workerWasmAllowed, "worker wasm"); + + await contentPage.close(); + await tabPage.close(); + + await extension.unload(); + + Services.mm.removeDelayedFrameScript(frameScriptURL); +} + +add_task(async function testCSP() { + await testPolicy({ + manifest_version: 2, + customCSP: null, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: true, + }, + }); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + await testPolicy({ + manifest_version: 2, + customCSP: { + "script-src": `'self' https://*.example.com 'unsafe-eval' ${hash}`, + }, + expects: { + workerEvalAllowed: true, + workerImportAllowed: false, + workerWasmAllowed: true, + }, + }); + + await testPolicy({ + manifest_version: 2, + customCSP: { + "script-src": `'self'`, + }, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: true, + }, + }); + + await testPolicy({ + manifest_version: 3, + customCSP: { + "script-src": `'self' ${hash}`, + "worker-src": `'self'`, + }, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: false, + }, + }); + + await testPolicy({ + manifest_version: 3, + customCSP: { + "script-src": `'self'`, + "worker-src": `'self'`, + }, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: false, + }, + }); + + await testPolicy({ + manifest_version: 3, + customCSP: { + "script-src": `'self' 'wasm-unsafe-eval'`, + "worker-src": `'self' 'wasm-unsafe-eval'`, + }, + expects: { + workerEvalAllowed: false, + workerImportAllowed: false, + workerWasmAllowed: true, + }, + }); +}); 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..d35f572731 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js @@ -0,0 +1,270 @@ +"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: { + browser_specific_settings: { + 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: { + browser_specific_settings: { + 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..d3f653f5d7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js @@ -0,0 +1,359 @@ +"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 => { + const { ExtensionContent } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm" + ); + this.context = ExtensionContent.getContextByExtensionId( + 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(); +}); + +add_task(async function test_contentscript_context_incognito_not_allowed() { + 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"); + }, + }, + }); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.setBoolPref("dom.security.https_first_pbm", false); + } + + 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 => { + const { ExtensionContent } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm" + ); + let context = ExtensionContent.getContextByExtensionId( + extensionId, + this.content + ); + Assert.equal( + context, + null, + "Extension unable to use content_script in private browsing window" + ); + }); + + await contentPage.close(); + await extension.unload(); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.clearUserPref("dom.security.https_first_pbm"); + } +}); + +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 => { + const { ExtensionContent } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm" + ); + // Save context so we can verify that contentWindow is nulled after unload. + this.context = ExtensionContent.getContextByExtensionId( + 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.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + 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) { + const { ExtensionContent } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm" + ); + context = ExtensionContent.getContextByExtensionId( + 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..ccc7f1452f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js @@ -0,0 +1,168 @@ +"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 => { + const { ExtensionContent } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm" + ); + this.context = ExtensionContent.getContextByExtensionId( + 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"); + + async function testWithoutBfcache() { + return 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 runWithPrefs( + [["docshell.shistory.bfcache.allow_unload_listeners", false]], + testWithoutBfcache + ); + + 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..c404cdb79a --- /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 { + browser_specific_settings: { + gecko: { id: expectedManifestGeckoId }, + }, + } = chrome.runtime.getManifest(); + let { + browser_specific_settings: { + gecko: { id: actualManifestGeckoId }, + }, + } = manifest; + + browser.test.assertEq( + actualManifestGeckoId, + expectedManifestGeckoId, + "the add-on manifest should be accessible from the created iframe" + ); + + let { + browser_specific_settings: { + 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: { + browser_specific_settings: { 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..6b03f5b0b0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js @@ -0,0 +1,433 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +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); + }); +} + +async function testHttpRequestUpgraded(data = {}) { + let f = data.content ? content.fetch : fetch; + return f(data.url) + .then(() => "http:") + .catch(() => "https:"); +} + +async function testWebSocketUpgraded(data = {}) { + let ws = data.content ? content.WebSocket : WebSocket; + new ws(data.url); +} + +function webSocketUpgradeListenerBackground() { + // Catch websocket requests and send the protocol back to be asserted. + browser.webRequest.onBeforeRequest.addListener( + details => { + // Send the protocol back as test result. + // This will either be "wss:", "ws:" + browser.test.sendMessage("result", new URL(details.url).protocol); + return { cancel: true }; + }, + { urls: ["wss://example.com/*", "ws://example.com/*"] }, + ["blocking"] + ); +} + +// 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", + }, + }, + + // 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, + }, + { + description: "content.WebSocket in content script is affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { content: true, url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + expect: "wss:", // we expect the websocket to be upgraded. + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "WebSocket in content script is not affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + expect: "ws:", // we expect the websocket to not be upgraded. + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "WebSocket in content script is not affected by page csp. v3", + version: 3, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "ws://example.com/ws_dummy" }, + script: testWebSocketUpgraded, + // TODO bug 1766813: MV3+WebSocket should use content script CSP. + expect: "wss:", // TODO: we expect the websocket to not be upgraded (ws:). + backgroundScript: webSocketUpgradeListenerBackground, + }, + { + description: "Http request in content script is not affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + expect: "http:", // we expect the request to not be upgraded. + }, + { + description: + "Http request in content script is not affected by page csp. v3", + version: 3, + pageCSP: `upgrade-insecure-requests;`, + data: { url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + // TODO bug 1766813: MV3+fetch should use content script CSP. + expect: "https:", // TODO: we expect the request to not be upgraded (http:). + }, + { + description: "content.fetch in content script is affected by page csp.", + version: 2, + pageCSP: `upgrade-insecure-requests;`, + data: { content: true, url: "http://example.com/plain.html" }, + script: testHttpRequestUpgraded, + expect: "https:", // we expect the request to be upgraded. + }, +]; + +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: ["webRequest", "webRequestBlocking"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + background: { scripts: ["background.js"] }, + }, + temporarilyInstalled: true, + files: { + "content_script.js": ` + (${contentScript})(${JSON.stringify(test.report)}).then(() => { + browser.test.sendMessage("violationEvent"); + }); + (${test.script})(${JSON.stringify(test.data)}).then(result => { + if(result !== undefined) { + browser.test.sendMessage("result", result); + } + }); + `, + "background.js": `(${test.backgroundScript || (() => {})})()`, + ...test.files, + }, + }; + + 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_dynamic_registration.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js new file mode 100644 index 0000000000..3a632e0107 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_dynamic_registration.js @@ -0,0 +1,206 @@ +"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(); + +// Do not use preallocated processes. +Services.prefs.setBoolPref("dom.ipc.processPrelaunch.enabled", false); +// This is needed for Android. +Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.web", 0); + +const makeExtension = ({ background, manifest }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + ...manifest, + permissions: + manifest.manifest_version === 3 ? ["scripting"] : ["http://*/*/*.html"], + }, + temporarilyInstalled: true, + background, + files: { + "script.js": () => { + browser.test.sendMessage( + `script-ran: ${location.pathname.split("/").pop()}` + ); + }, + "inject_browser.js": () => { + browser.userScripts.onBeforeScript.addListener(script => { + // Inject `browser.test.sendMessage()` so that it can be used in the + // `script.js` defined above when using "user scripts". + script.defineGlobals({ + browser: { + test: { + sendMessage(msg) { + browser.test.sendMessage(msg); + }, + }, + }, + }); + }); + }, + }, + }); +}; + +const verifyRegistrationWithNewProcess = async extension => { + // We override the `broadcast()` method to reliably verify Bug 1756495: when + // a new process is spawned while we register a content script, the script + // should be correctly registered and executed in this new process. Below, + // when we receive the `Extension:RegisterContentScripts`, we open a new tab + // (which is the "new process") and then we invoke the original "broadcast + // logic". The expected result is that the content script registered by the + // extension will run. + const originalBroadcast = Extension.prototype.broadcast; + + let broadcastCalledCount = 0; + let secondContentPage; + + extension.extension.broadcast = async function broadcast(msg, data) { + if (msg !== "Extension:RegisterContentScripts") { + return originalBroadcast.call(this, msg, data); + } + + broadcastCalledCount++; + Assert.equal( + 1, + broadcastCalledCount, + "broadcast override should be called once" + ); + + await originalBroadcast.call(this, msg, data); + + Assert.equal(extension.id, data.id, "got expected extension ID"); + Assert.equal(1, data.scripts.length, "expected 1 script to register"); + Assert.ok( + data.scripts[0].options.jsPaths[0].endsWith("script.js"), + "got expected js file" + ); + + const newPids = []; + const topic = "ipc:content-created"; + + let obs = (subject, topic, data) => { + newPids.push(parseInt(data, 10)); + }; + Services.obs.addObserver(obs, topic); + + secondContentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy_page.html` + ); + + const { + childID, + } = secondContentPage.browsingContext.currentWindowGlobal.domProcess; + + Services.obs.removeObserver(obs, topic); + + // We expect to have a new process created for `secondContentPage`. + Assert.ok( + newPids.includes(childID), + `expected PID ${childID} to be in [${newPids.join(", ")}])` + ); + }; + + await extension.startup(); + await extension.awaitMessage("background-done"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await Promise.all([ + extension.awaitMessage("script-ran: file_sample.html"), + extension.awaitMessage("script-ran: dummy_page.html"), + ]); + + // Unload extension first to avoid an issue on Windows platforms. + await extension.unload(); + await contentPage.close(); + await secondContentPage.close(); +}; + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_scripting_registerContentScripts() { + let extension = makeExtension({ + manifest: { + manifest_version: 3, + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + }, + async background() { + const script = { + id: "a-script", + js: ["script.js"], + matches: ["http://*/*/*.html"], + persistAcrossSessions: false, + }; + + await browser.scripting.registerContentScripts([script]); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); + +add_task( + { + // We don't have WebIDL bindings for `browser.contentScripts`. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_contentScripts_register() { + let extension = makeExtension({ + manifest: { + manifest_version: 2, + }, + async background() { + await browser.contentScripts.register({ + js: [{ file: "script.js" }], + matches: ["http://*/*/*.html"], + }); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); + +add_task( + { + // We don't have WebIDL bindings for `browser.userScripts`. + skip_if: () => ExtensionTestUtils.isInBackgroundServiceWorkerTests(), + }, + async function test_userScripts_register() { + let extension = makeExtension({ + manifest: { + manifest_version: 2, + user_scripts: { + api_script: "inject_browser.js", + }, + }, + async background() { + await browser.userScripts.register({ + js: [{ file: "script.js" }], + matches: ["http://*/*/*.html"], + }); + + browser.test.sendMessage("background-done"); + }, + }); + + await verifyRegistrationWithNewProcess(extension); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js new file mode 100644 index 0000000000..a9daa9d7ab --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_errors.js @@ -0,0 +1,127 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; +const TEST_URL_1 = `${BASE_URL}/file_sample.html`; +const TEST_URL_2 = `${BASE_URL}/file_content_script_errors.html`; + +// 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_cached_contentscript_on_document_start() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + // Use distinct content scripts as some will throw and would prevent executing the next script + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script1.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script2.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script3.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script4.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_content_script_errors.html"], + js: ["script5.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "script1.js": ` + throw new Error("Object exception"); + `, + "script2.js": ` + throw "String exception"; + `, + "script3.js": ` + undefinedSymbol(); + `, + "script4.js": ` + ) + `, + "script5.js": ` + Promise.reject("rejected promise"); + + (async () => { + /* make the async, really async */ + await new Promise(r => setTimeout(r, 0)); + throw new Error("async function exception"); + })(); + + setTimeout(() => { + asyncUndefinedSymbol(); + }); + + /* Use a delay in order to resume test execution after these async errors */ + setTimeout(() => { + browser.test.sendMessage("content-script-loaded"); + }, 500); + `, + }, + }); + + await extension.startup(); + + // Load a first page in order to be able to register a console listener in the content process. + // This has to be done in the same domain of the second page to stay in the same process. + let contentPage = await ExtensionTestUtils.loadContentPage(TEST_URL_1); + + // Listen to the errors logged in the content process. + ContentTask.spawn(contentPage.browser, {}, () => { + this.collectedErrors = []; + + this.consoleErrorListener = error => { + error.QueryInterface(Ci.nsIScriptError); + // Ignore errors from ExtensionContent.jsm + if (error.innerWindowID) { + this.collectedErrors.push({ + innerWindowID: error.innerWindowID, + message: error.errorMessage, + }); + } + }; + + Services.console.registerListener(this.consoleErrorListener); + }); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(TEST_URL_2); + + await extension.awaitMessage("content-script-loaded"); + + const errors = await ContentTask.spawn(contentPage.browser, {}, () => { + Services.console.unregisterListener(this.consoleErrorListener); + return this.collectedErrors; + }); + equal(errors.length, 7); + for (const { innerWindowID, message } of errors) { + equal( + innerWindowID, + contentPage.browser.innerWindowID, + `Message ${message} has the innerWindowID set` + ); + } + + await extension.unload(); + + 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_importmap.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js new file mode 100644 index 0000000000..7e7ca2720d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_importmap.js @@ -0,0 +1,124 @@ +"use strict"; + +// Currently import maps are not supported for web extensions, neither for +// content scripts nor moz-extension documents. +// For content scripts that's because they use their own sandbox module loaders, +// which is different from the DOM module loader. +// As for moz-extension documents, that's because inline script tags is not +// allowed by CSP. (Currently import maps can be only added through inline +// script tag.) +// +// This test is used to verified import maps are not supported for web +// extensions. +// See Bug 1765275: Enable Import maps for web extension content scripts. +Services.prefs.setBoolPref("dom.importMaps.enabled", true); + +const server = createHttpServer({ hosts: ["example.com"] }); + +const importMapString = ` + <script type="importmap"> + { + "imports": { + "simple": "./simple.js", + "simple2": "./simple2.js" + } + } + </script>`; + +const importMapHtml = ` + <!DOCTYPE html> + <html> + <meta charset=utf-8> + <title>Test a simple import map in normal webpage</title> + <body> + ${importMapString} + </body></html>`; + +// page.html will load page.js, which will call import(); +const pageHtml = ` + <!DOCTYPE html> + <html> + <meta charset=utf-8> + <title>Test a simple import map in moz-extension documents</title> + <body> + ${importMapString} + <script src="page.js"></script> + </body></html>`; + +const simple2JS = `export let foo = 2;`; + +server.registerPathHandler("/importmap.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(importMapHtml); +}); + +server.registerPathHandler("/simple.js", (request, response) => { + ok(false, "Unexpected request to /simple.js"); +}); + +server.registerPathHandler("/simple2.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/javascript", false); + response.write(simple2JS); +}); + +add_task(async function test_importMaps_not_supported() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/importmap.html"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function() { + // Content scripts shouldn't be able to use the bare specifier from + // the import map. + await browser.test.assertRejects( + import("simple"), + /The specifier “simple” was a bare specifier/, + `should reject import("simple")` + ); + + browser.test.sendMessage("done"); + }, + "page.html": pageHtml, + "page.js": async function() { + await browser.test.assertRejects( + import("simple"), + /The specifier “simple” was a bare specifier/, + `should reject import("simple")` + ); + browser.test.sendMessage("page-done"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/importmap.html" + ); + await extension.awaitMessage("done"); + + await contentPage.spawn(null, async () => { + // Import maps should work for documents. + let promise = content.eval(`import("simple2")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "mod.foo should be 2"); + }); + + // moz-extension documents doesn't allow inline scripts, so the import map + // script tag won't be processed. + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-done"); + + await page.close(); + await extension.unload(); + await contentPage.close(); +}); 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..e813b46ca0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js @@ -0,0 +1,43 @@ +/* -*- 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 content_script_in_background_frame() { + async function background() { + const FRAME_URL = "http://example.com:8888/dummyFrame"; + 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}`); + browser.test.sendMessage("done_in_content_script"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + }, + files: { + "contentscript.js": contentScript, + }, + background, + }); + await extension.startup(); + await extension.awaitMessage("done_in_content_script"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js new file mode 100644 index 0000000000..ca37e2e951 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_json_api.js @@ -0,0 +1,102 @@ +"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.write( + `<script> + // Clobber the JSON API to allow us to confirm that the page's value for + // the "JSON" object does not affect the content script's JSON API. + window.JSON = new String("overridden by page"); + window.objFromPage = { serializeMe: "thanks" }; + window.objWithToJSON = { toJSON: () => "toJSON ran", should_not_see: 1 }; + </script> + ` + ); +}); + +async function test_JSON_parse_and_stringify({ manifest_version }) { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version, + granted_host_permissions: true, // Test-only: grant permissions in MV3. + host_permissions: ["http://example.com/"], // Work-around for bug 1766752. + content_scripts: [ + { + matches: ["http://example.com/dummy"], + run_at: "document_end", + js: ["contentscript.js"], + }, + ], + }, + files: { + "contentscript.js"() { + let json = `{"a":[123,true,null]}`; + browser.test.assertEq( + JSON.stringify({ a: [123, true, null] }), + json, + "JSON.stringify with basic values" + ); + let parsed = JSON.parse(json); + browser.test.assertTrue( + parsed instanceof Object, + "Parsed JSON is an Object" + ); + browser.test.assertTrue( + parsed.a instanceof Array, + "Parsed JSON has an Array" + ); + browser.test.assertEq( + JSON.stringify(parsed), + json, + "JSON.stringify for parsed JSON returns original input" + ); + browser.test.assertEq( + JSON.stringify({ toJSON: () => "overridden", hideme: true }), + `"overridden"`, + "JSON.parse with toJSON method" + ); + + browser.test.assertEq( + JSON.stringify(window.wrappedJSObject.objFromPage), + `{"serializeMe":"thanks"}`, + "JSON.parse with value from the page" + ); + + browser.test.assertEq( + JSON.stringify(window.wrappedJSObject.objWithToJSON), + `"toJSON ran"`, + "JSON.parse with object with toJSON method from the page" + ); + + browser.test.assertTrue(JSON === globalThis.JSON, "JSON === this.JSON"); + browser.test.assertTrue(JSON === window.JSON, "JSON === window.JSON"); + browser.test.assertEq( + "overridden by page", + window.wrappedJSObject.JSON.toString(), + "page's JSON object is still the original value (overridden by page)" + ); + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_JSON_apis_MV2() { + await test_JSON_parse_and_stringify({ manifest_version: 2 }); +}); + +add_task(async function test_JSON_apis_MV3() { + await test_JSON_parse_and_stringify({ manifest_version: 3 }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js new file mode 100644 index 0000000000..3c23eb4dc3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_module_import.js @@ -0,0 +1,277 @@ +"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>"); +}); + +server.registerPathHandler("/script.js", (request, response) => { + ok(false, "Unexpected request to /script.js"); +}); + +/* eslint-disable no-unsanitized/method, no-eval, no-implied-eval */ + +const MODULE1 = ` + import {foo} from "./module2.js"; + export let bar = foo; + + let count = 0; + + export function counter () { return count++; } +`; + +const MODULE2 = `export let foo = 2;`; + +add_task(async function test_disallowed_import() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function() { + let disallowedURLs = [ + "data:text/javascript,void 0", + "javascript:void 0", + "http://example.com/script.js", + URL.createObjectURL( + new Blob(["void 0", { type: "text/javascript" }]) + ), + ]; + + for (let url of disallowedURLs) { + await browser.test.assertRejects( + import(url), + /error loading dynamically imported module/, + `should reject import("${url}")` + ); + } + + browser.test.sendMessage("done"); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_normal_import() { + Services.prefs.setBoolPref("extensions.content_web_accessible.enabled", true); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + }, + + files: { + "main.js": async function() { + /* global exportFunction */ + const url = browser.runtime.getURL("module1.js"); + + await browser.test.assertRejects( + import(url), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + await browser.test.assertRejects( + window.eval(`import("${url}")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + let promise = new Promise((resolve, reject) => { + exportFunction(resolve, window, { defineAs: "resolve" }); + exportFunction(reject, window, { defineAs: "reject" }); + }); + + window.setTimeout(`import("${url}").then(resolve, reject)`, 0); + + await browser.test.assertRejects( + promise, + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible from page context" + ); + + browser.test.sendMessage("done"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await extension.awaitMessage("done"); + + // Web page can not import non-web-accessible files. + await contentPage.spawn(extension.uuid, async uuid => { + let files = ["main.js", "module1.js", "module2.js"]; + + for (let file of files) { + let url = `moz-extension://${uuid}/${file}`; + await Assert.rejects( + content.eval(`import("${url}")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + } + }); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_import_web_accessible() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + web_accessible_resources: ["module1.js", "module2.js"], + }, + + files: { + "main.js": async function() { + let mod = await import(browser.runtime.getURL("module1.js")); + browser.test.assertEq(mod.bar, 2); + browser.test.assertEq(mod.counter(), 0); + browser.test.sendMessage("done"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + + // Web page can import web-accessible files, + // even after WebExtension imported the same files. + await contentPage.spawn(extension.uuid, async uuid => { + let base = `moz-extension://${uuid}`; + + await Assert.rejects( + content.eval(`import("${base}/main.js")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + + let promise = content.eval(`import("${base}/module1.js")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.bar, 2, "exported value should match"); + Assert.equal(mod.counter(), 0, "Counter should be fresh"); + Assert.equal(mod.counter(), 1, "Counter should be fresh"); + + promise = content.eval(`import("${base}/module2.js")`); + mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "exported value should match"); + }); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_import_web_accessible_after_page() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["main.js"], + }, + ], + web_accessible_resources: ["module1.js", "module2.js"], + }, + + files: { + "main.js": async function() { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "import"); + + const url = browser.runtime.getURL("module1.js"); + let mod = await import(url); + browser.test.assertEq(mod.bar, 2); + browser.test.assertEq(mod.counter(), 0, "Counter should be fresh"); + + let promise = window.eval(`import("${url}")`); + let mod2 = (await promise.wrappedJSObject).wrappedJSObject; + browser.test.assertEq( + mod2.counter(), + 2, + "Counter should have been incremented by page" + ); + + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + "module1.js": MODULE1, + "module2.js": MODULE2, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("ready"); + + // The web page imports the web-accessible files first, + // when the WebExtension imports the same file, they should + // not be shared. + await contentPage.spawn(extension.uuid, async uuid => { + let base = `moz-extension://${uuid}`; + + await Assert.rejects( + content.eval(`import("${base}/main.js")`), + /error loading dynamically imported module/, + "Cannot import script that is not web-accessible" + ); + + let promise = content.eval(`import("${base}/module1.js")`); + let mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.bar, 2, "exported value should match"); + Assert.equal(mod.counter(), 0); + Assert.equal(mod.counter(), 1); + + promise = content.eval(`import("${base}/module2.js")`); + mod = (await promise.wrappedJSObject).wrappedJSObject; + Assert.equal(mod.foo, 2, "exported value should match"); + }); + + extension.sendMessage("import"); + + await extension.awaitMessage("done"); + + await extension.unload(); + await contentPage.close(); +}); 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..2546b257fb --- /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/data/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/data/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/data/file_download.txt"; + document.head.appendChild(c); + }, + }, + }); + + let page = await ExtensionTestUtils.loadContentPage( + "http://a.example.com/data/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, 46, "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_permissions_change.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js new file mode 100644 index 0000000000..fbf5cb2906 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_change.js @@ -0,0 +1,104 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com", "example.net"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const HOSTS = ["http://example.com/*", "http://example.net/*"]; + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +function grantOptional({ extension: ext }, origins) { + return ExtensionPermissions.add(ext.id, { origins, permissions: [] }, ext); +} + +function revokeOptional({ extension: ext }, origins) { + return ExtensionPermissions.remove(ext.id, { origins, permissions: [] }, ext); +} + +function makeExtension(id, content_scripts) { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + + browser_specific_settings: { gecko: { id } }, + content_scripts, + + permissions: ["scripting"], + host_permissions: HOSTS, + }, + files: { + "cs.js"() { + browser.test.log(`${browser.runtime.id} script on ${location.host}`); + browser.test.sendMessage(`${browser.runtime.id}_on_${location.host}`); + }, + }, + background() { + browser.test.onMessage.addListener(async (msg, origins) => { + browser.test.log(`${browser.runtime.id} registering content scripts`); + await browser.scripting.registerContentScripts([ + { + id: "cs1", + persistAcrossSessions: false, + matches: origins, + js: ["cs.js"], + }, + ]); + browser.test.sendMessage("done"); + }); + }, + }); +} + +// Test that content scripts in MV3 enforce origin permissions. +// Test granted optional permissions are available in newly spawned processes. +add_task(async function test_contentscript_mv3_permissions() { + // Alpha lists content scripts in the manifest. + let alpha = makeExtension("alpha@test", [{ matches: HOSTS, js: ["cs.js"] }]); + let beta = makeExtension("beta@test"); + + await grantOptional(alpha, HOSTS); + await grantOptional(beta, ["http://example.net/*"]); + info("Granted initial permissions for both."); + + await alpha.startup(); + await beta.startup(); + + // Beta registers same content scripts using the scripting api. + beta.sendMessage("register", HOSTS); + await beta.awaitMessage("done"); + + // Only Alpha has origin permissions for example.com. + { + let page = await ExtensionTestUtils.loadContentPage( + `http://example.com/data/file_sample.html` + ); + info("Loaded a page from example.com."); + + await alpha.awaitMessage("alpha@test_on_example.com"); + info("Got a message from alpha@test on example.com."); + await page.close(); + } + + await revokeOptional(alpha, ["http://example.net/*"]); + info("Revoked example.net permissions from Alpha."); + + // Now only Beta has origin permissions for example.net. + { + let page = await ExtensionTestUtils.loadContentPage( + `http://example.net/data/file_sample.html` + ); + info("Loaded a page from example.net."); + + await beta.awaitMessage("beta@test_on_example.net"); + info("Got a message from beta@test on example.net."); + await page.close(); + } + + info("Done, unloading Alpha and Beta."); + await beta.unload(); + await alpha.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js new file mode 100644 index 0000000000..611ff07c05 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_permissions_fetch.js @@ -0,0 +1,87 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com", "example.net"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +function grantOptional({ extension: ext }, origins) { + return ExtensionPermissions.add(ext.id, { origins, permissions: [] }, ext); +} + +function revokeOptional({ extension: ext }, origins) { + return ExtensionPermissions.remove(ext.id, { origins, permissions: [] }, ext); +} + +// Test granted optional permissions work with XHR/fetch in new processes. +add_task( + { + pref_set: [["dom.ipc.keepProcessesAlive.extension", 0]], + }, + async function test_fetch_origin_permissions_change() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + host_permissions: ["http://example.com/*"], + optional_permissions: ["http://example.net/*"], + }, + files: { + "page.js"() { + fetch("http://example.net/data/file_sample.html") + .then(req => req.text()) + .then(text => browser.test.sendMessage("done", { text })) + .catch(e => browser.test.sendMessage("done", { error: e.message })); + }, + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + }, + }); + + await extension.startup(); + + let osPid; + { + // Grant permissions before extension process exists. + await grantOptional(extension, ["http://example.net/*"]); + + let page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html") + ); + + let { text } = await extension.awaitMessage("done"); + ok(text.includes("Sample text"), "Can read from granted optional host."); + + osPid = page.browsingContext.currentWindowGlobal.osPid; + await page.close(); + } + + // Release the extension process so that next part starts a new one. + Services.ppmm.releaseCachedProcesses(); + + { + // Revoke permissions and confirm fetch fails. + await revokeOptional(extension, ["http://example.net/*"]); + + let page = await ExtensionTestUtils.loadContentPage( + extension.extension.baseURI.resolve("page.html") + ); + + let { error } = await extension.awaitMessage("done"); + ok(error.includes("NetworkError"), `Expected error: ${error}`); + + if (WebExtensionPolicy.useRemoteWebExtensions) { + notEqual( + osPid, + page.browsingContext.currentWindowGlobal.osPid, + "Second part of the test used a new process." + ); + } + + await page.close(); + } + + await extension.unload(); + } +); 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..d775bb2cfb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js @@ -0,0 +1,149 @@ +"use strict"; + +function makeExtension({ id, isPrivileged, withScriptingAPI = false }) { + let permissions = []; + let content_scripts = []; + let background = () => { + browser.test.sendMessage("background-ready"); + }; + + if (isPrivileged) { + permissions.push("mozillaAddons"); + } + + if (withScriptingAPI) { + permissions.push("scripting"); + // When we don't use a content script registered via the manifest, we + // should add the origin as a permission. + permissions.push("resource://foo/file_sample.html"); + + // Redefine background script to dynamically register the content script. + if (isPrivileged) { + background = async () => { + await browser.scripting.registerContentScripts([ + { + id: "content_script", + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + persistAcrossSessions: false, + runAt: "document_start", + }, + ]); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 script"); + + browser.test.sendMessage("background-ready"); + }; + } else { + background = async () => { + await browser.test.assertRejects( + browser.scripting.registerContentScripts([ + { + id: "content_script", + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + persistAcrossSessions: false, + runAt: "document_start", + }, + ]), + /Invalid url pattern: resource:/, + "got expected error" + ); + + browser.test.sendMessage("background-ready"); + }; + } + } else { + content_scripts.push({ + js: ["content_script.js"], + matches: ["resource://foo/file_sample.html"], + run_at: "document_start", + }); + } + + return ExtensionTestUtils.loadExtension({ + isPrivileged, + + manifest: { + manifest_version: 2, + browser_specific_settings: { gecko: { id } }, + content_scripts, + permissions, + }, + + background, + + 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}`); + }, + }, + }); +} + +const verifyRestrictSchemes = async ({ withScriptingAPI }) => { + 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({ + id: "unprivileged@tests.mozilla.org", + isPrivileged: false, + withScriptingAPI, + }); + let privileged = makeExtension({ + id: "privileged@tests.mozilla.org", + isPrivileged: true, + withScriptingAPI, + }); + + await unprivileged.startup(); + await unprivileged.awaitMessage("background-ready"); + + await privileged.startup(); + await privileged.awaitMessage("background-ready"); + + 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(); +}; + +// Bug 1780507: this only works with MV2 currently because MV3's optional +// permission mechanism lacks `restrictSchemes` flags. +add_task(async function test_contentscript_restrictSchemes_mv2() { + await verifyRestrictSchemes({ withScriptingAPI: false }); +}); + +// Bug 1780507: this only works with MV2 currently because MV3's optional +// permission mechanism lacks `restrictSchemes` flags. +add_task(async function test_contentscript_restrictSchemes_scripting_mv2() { + await verifyRestrictSchemes({ withScriptingAPI: true }); +}); 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..7a0325ae95 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js @@ -0,0 +1,101 @@ +"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 = []; + { + const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" + ); + 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..6f2ee165f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js @@ -0,0 +1,1383 @@ +/* -*- 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. + */ + +// 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 {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 }; +} + +/** + * @typedef InjectedUrl + * A URL present in styles injected by the content script. + * @type {object} + * @property {string} origin + * The origin of the URL, one of "page", "contentScript", or "extension". + * @param {string} href + * The URL string. + * @param {boolean} inline + * If true, the URL is present in an inline stylesheet, which may be + * blocked by CSP prior to parsing, depending on its origin. + */ + +/** + * @typedef InjectedSource + * An inline CSS source injected by the content script. + * @type {object} + * @param {string} origin + * The origin of the CSS, one of "page", "contentScript", or "extension". + * @param {string} css + * The CSS source text. + */ + +/** + * 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<InjectedUrl>} message.urls + * A list of URLs present in styles injected by the content script. + * @param {Array<InjectedSource>} message.sources + * A list of inline CSS sources injected by the content script. + * @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(Services.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(Services.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, + host_permissions: ["http://example.com/*"], + granted_host_permissions: true, + }, + temporarilyInstalled: true, + }; + + 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..8a58b2475c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js @@ -0,0 +1,62 @@ +"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"], + }, + ], + }, + + files: { + "cs.js"() { + browser.test.assertEq( + location.href, + "http://example.org/data/file_iframe.html", + "url is ok" + ); + + // frameId is the BrowsingContext ID in practice. + let frameId = browser.runtime.getFrameId(window); + browser.test.sendMessage("content-script-loaded", frameId); + }, + }, + }); + + 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..028f5b5638 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js @@ -0,0 +1,201 @@ +"use strict"; + +const global = this; + +var { BaseContext, EventManager, EventEmitter } = ExtensionCommon; + +class FakeExtension extends EventEmitter { + constructor(id) { + super(); + this.id = id; + } +} + +class StubContext extends BaseContext { + constructor() { + let fakeExtension = new FakeExtension("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 = new FakeExtension("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..2cb435ee54 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js @@ -0,0 +1,277 @@ +"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.importESModule( + "resource://testing-common/TestUtils.sys.mjs" + ); + 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 => { + const { ExtensionContent } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm" + ); + let frame = this.content.document.querySelector( + "iframe[src*='file_iframe.html']" + ); + let context = ExtensionContent.getContextByExtensionId( + 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 => { + const { ExtensionContent } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm" + ); + let context = ExtensionContent.getContextByExtensionId( + 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..2a9132a3cc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js @@ -0,0 +1,591 @@ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); +ChromeUtils.defineESModuleGetters(this, { + ContextualIdentityService: + "resource://gre/modules/ContextualIdentityService.sys.mjs", +}); + +const CONTAINERS_PREF = "privacy.userContext.enabled"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function startup() { + await AddonTestUtils.promiseStartupManager(); +}); + +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: { + browser_specific_settings: { + 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: { + browser_specific_settings: { + 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: { + browser_specific_settings: { + 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: { + browser_specific_settings: { + 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, + browser_specific_settings: { + 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); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_contextualIdentity_event_page() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { + gecko: { id: "eventpage@mochitest" }, + }, + permissions: ["contextualIdentities"], + background: { persistent: false }, + }, + background() { + browser.contextualIdentities.onCreated.addListener(() => { + browser.test.sendMessage("created"); + }); + browser.contextualIdentities.onUpdated.addListener(() => {}); + browser.contextualIdentities.onRemoved.addListener(() => { + browser.test.sendMessage("removed"); + }); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = ["onCreated", "onUpdated", "onRemoved"]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "contextualIdentities", event, { + primed: false, + }); + } + + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "contextualIdentities", event, { + primed: true, + }); + } + + // test events waken background + let identity = ContextualIdentityService.create("foobar", "circle", "red"); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("created"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "contextualIdentities", event, { + primed: false, + }); + } + + ContextualIdentityService.remove(identity.userContextId); + await extension.awaitMessage("removed"); + + // check primed listeners after startup + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "contextualIdentities", event, { + primed: true, + }); + } + + await extension.unload(); + } +); 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..2a23fbd71d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js @@ -0,0 +1,567 @@ +"use strict"; + +const { UrlClassifierTestUtils } = ChromeUtils.import( + "resource://testing-common/UrlClassifierTestUtils.jsm" +); + +const { + // cookieBehavior constants. + BEHAVIOR_REJECT, + BEHAVIOR_REJECT_TRACKER, +} = 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"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js new file mode 100644 index 0000000000..1c40f2f73f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_errors.js @@ -0,0 +1,168 @@ +"use strict"; + +add_task(async function setup_cookies() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + const url = "http://example.com/"; + const name = "dummyname"; + await browser.cookies.set({ url, name, value: "from_setup:normal" }); + await browser.cookies.set({ + url, + name, + value: "from_setup:private", + storeId: "firefox-private", + }); + await browser.cookies.set({ + url, + name, + value: "from_setup:container", + storeId: "firefox-container-1", + }); + browser.test.sendMessage("setup_done"); + }, + manifest: { + permissions: ["cookies", "http://example.com/"], + }, + }); + await extension.startup(); + await extension.awaitMessage("setup_done"); + await extension.unload(); +}); + +add_task(async function test_error_messages() { + async function background() { + const url = "http://example.com/"; + const name = "dummyname"; + // Shorthands to minimize boilerplate. + const set = d => browser.cookies.set({ url, name, value: "x", ...d }); + const remove = d => browser.cookies.remove({ url, name, ...d }); + const get = d => browser.cookies.get({ url, name, ...d }); + const getAll = d => browser.cookies.getAll(d); + + // Host permission permission missing. + await browser.test.assertRejects( + set({}), + /^Permission denied to set cookie \{.*\}$/, + "cookies.set without host permissions rejects with error" + ); + browser.test.assertEq( + null, + await remove({}), + "cookies.remove without host permissions does not remove any cookies" + ); + browser.test.assertEq( + null, + await get({}), + "cookies.get without host permissions does not match any cookies" + ); + browser.test.assertEq( + "[]", + JSON.stringify(await getAll({})), + "cookies.getAll without host permissions does not match any cookies" + ); + + // Private browsing cookies without access to private browsing mode. + await browser.test.assertRejects( + set({ storeId: "firefox-private" }), + "Extension disallowed access to the private cookies storeId.", + "cookies.set cannot modify private cookies without permission" + ); + await browser.test.assertRejects( + remove({ storeId: "firefox-private" }), + "Extension disallowed access to the private cookies storeId.", + "cookies.remove cannot modify private cookies without permission" + ); + await browser.test.assertRejects( + get({ storeId: "firefox-private" }), + "Extension disallowed access to the private cookies storeId.", + "cookies.get cannot read private cookies without permission" + ); + await browser.test.assertRejects( + getAll({ storeId: "firefox-private" }), + "Extension disallowed access to the private cookies storeId.", + "cookies.getAll cannot read private cookies without permission" + ); + + // On Android, any firefox-container-... is treated as valid, so it doesn't + // result in an error. However, because the test extension does not have + // any host permissions, it will fail with an error any way (but a + // different one than expected). + // TODO bug 1743616: Fix implementation and this test. + const kErrorInvalidContainer = navigator.userAgent.includes("Android") + ? /Permission denied to set cookie/ + : `Invalid cookie store id: "firefox-container-99"`; + + // Invalid storeId. + await browser.test.assertRejects( + set({ storeId: "firefox-container-99" }), + kErrorInvalidContainer, + "cookies.set with invalid storeId (non-existent container)" + ); + + await browser.test.assertRejects( + set({ storeId: "0" }), + `Invalid cookie store id: "0"`, + "cookies.set with invalid storeId (format not recognized)" + ); + + for (let method of [remove, get, getAll]) { + let resultWithInvalidStoreId = method == getAll ? [] : null; + browser.test.assertEq( + JSON.stringify(await method({ storeId: "firefox-container-99" })), + JSON.stringify(resultWithInvalidStoreId), + `cookies.${method.name} with invalid storeId (non-existent container)` + ); + + browser.test.assertEq( + JSON.stringify(await method({ storeId: "0" })), + JSON.stringify(resultWithInvalidStoreId), + `cookies.${method.name} with invalid storeId (format not recognized)` + ); + } + + browser.test.sendMessage("test_done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies"], + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +add_task(async function expected_cookies_at_end_of_test() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + async function checkCookie(storeId, value) { + let cookies = await browser.cookies.getAll({ storeId }); + let index = cookies.findIndex(c => c.value === value); + browser.test.assertTrue(index !== -1, `Found cookie: ${value}`); + if (index >= 0) { + cookies.splice(index, 1); + } + browser.test.assertEq( + "[]", + JSON.stringify(cookies), + `No more cookies left in cookieStoreId=${storeId}` + ); + } + // Added in setup. + await checkCookie("firefox-default", "from_setup:normal"); + await checkCookie("firefox-private", "from_setup:private"); + await checkCookie("firefox-container-1", "from_setup:container"); + browser.test.sendMessage("final_check_done"); + }, + manifest: { + permissions: ["cookies", "<all_urls>"], + }, + }); + await extension.startup(); + await extension.awaitMessage("final_check_done"); + await extension.unload(); +}); 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_onChanged.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js new file mode 100644 index 0000000000..6eef222297 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_onChanged.js @@ -0,0 +1,142 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +// In this test, we want to check the behavior of extensions without private +// browsing access. Privileged add-ons automatically have private browsing +// access, so make sure that the test add-ons are not privileged. +AddonTestUtils.usePrivilegedSignatures = false; + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); + + Services.prefs.setBoolPref("extensions.eventPages.enabled", true); +}); + +function createTestExtension({ privateAllowed }) { + return ExtensionTestUtils.loadExtension({ + incognitoOverride: privateAllowed ? "spanning" : null, + manifest: { + permissions: ["cookies"], + host_permissions: ["https://example.com/"], + background: { persistent: false }, + }, + background() { + browser.cookies.onChanged.addListener(changeInfo => { + browser.test.sendMessage("cookie-event", changeInfo); + }); + }, + }); +} + +function addAndRemoveCookie({ isPrivate }) { + const cookie = { + name: "cookname", + value: "cookvalue", + domain: "example.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: false, + sameSite: "lax", + session: false, + firstPartyDomain: "", + partitionKey: null, + expirationDate: Date.now() + 3600000, + storeId: isPrivate ? "firefox-private" : "firefox-default", + }; + const originAttributes = { privateBrowsingId: isPrivate ? 1 : 0 }; + Services.cookies.add( + cookie.domain, + cookie.path, + cookie.name, + cookie.value, + cookie.secure, + cookie.httpOnly, + cookie.session, + cookie.expirationDate, + originAttributes, + Ci.nsICookie.SAMESITE_LAX, + Ci.nsICookie.SCHEME_HTTPS + ); + Services.cookies.remove( + cookie.domain, + cookie.name, + cookie.path, + originAttributes + ); + return cookie; +} + +add_task(async function test_onChanged_event_page() { + let nonPrivateExtension = createTestExtension({ privateAllowed: false }); + let privateExtension = createTestExtension({ privateAllowed: true }); + await privateExtension.startup(); + await nonPrivateExtension.startup(); + assertPersistentListeners(privateExtension, "cookies", "onChanged", { + primed: false, + }); + assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", { + primed: false, + }); + + // Suspend both event pages. + await privateExtension.terminateBackground(); + assertPersistentListeners(privateExtension, "cookies", "onChanged", { + primed: true, + }); + await nonPrivateExtension.terminateBackground(); + assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", { + primed: true, + }); + + // Modifying a private cookie should wake up the private extension, but not + // the other one that does not have access to private browsing data. + let privateCookie = addAndRemoveCookie({ isPrivate: true }); + + Assert.deepEqual( + await privateExtension.awaitMessage("cookie-event"), + { removed: false, cookie: privateCookie, cause: "explicit" }, + "cookies.onChanged for private cookie creation" + ); + Assert.deepEqual( + await privateExtension.awaitMessage("cookie-event"), + { removed: true, cookie: privateCookie, cause: "explicit" }, + "cookies.onChanged for private cookie removal" + ); + // Private extension should have awakened... + assertPersistentListeners(privateExtension, "cookies", "onChanged", { + primed: false, + }); + // ... but the non-private extension should still be sound asleep. + assertPersistentListeners(nonPrivateExtension, "cookies", "onChanged", { + primed: true, + }); + + // A non-private cookie modification should notify both extensions. + let nonPrivateCookie = addAndRemoveCookie({ isPrivate: false }); + Assert.deepEqual( + await privateExtension.awaitMessage("cookie-event"), + { removed: false, cookie: nonPrivateCookie, cause: "explicit" }, + "cookies.onChanged for cookie creation in privateExtension" + ); + Assert.deepEqual( + await privateExtension.awaitMessage("cookie-event"), + { removed: true, cookie: nonPrivateCookie, cause: "explicit" }, + "cookies.onChanged for cookie removal in privateExtension" + ); + Assert.deepEqual( + await nonPrivateExtension.awaitMessage("cookie-event"), + { removed: false, cookie: nonPrivateCookie, cause: "explicit" }, + "cookies.onChanged for cookie creation in nonPrivateExtension" + ); + Assert.deepEqual( + await nonPrivateExtension.awaitMessage("cookie-event"), + { removed: true, cookie: nonPrivateCookie, cause: "explicit" }, + "cookies.onChanged for cookie removal in nonPrivateCookie" + ); + + await privateExtension.unload(); + await nonPrivateExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js new file mode 100644 index 0000000000..248c7f584b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_partitionKey.js @@ -0,0 +1,898 @@ +"use strict"; + +/** + * This test verifies that the extension API's access to cookies is consistent + * with the cookies as seen by web pages under the following modes: + * - Every top-level document shares the same cookie jar, every subdocument of + * the top-level document has a distinct cookie jar tied to the site of the + * top-level document (dFPI). + * - All documents have a cookie jar keyed by the domain of the top-level + * document (FPI). + * - All cookies are in one cookie jar (classic behavior = no FPI nor dFPI) + * + * FPI and dFPI are implemented using OriginAttributes, and historically the + * consequence of not recognizing an origin attribute is that cookies cannot be + * deleted. Hence, the functionality of the cookies API is verified as follows, + * by the testCookiesAPI/runTestCase methods. + * + * 1. Load page that creates cookies for the top and a framed document: + * - "delete_me" + * - "edit_me" + * 2. cookies.getAll: get all cookies with extension API. + * 3. cookies.remove: Remove "delete_me" cookies with the extension API. + * 4. cookies.set: Edit "edit_me" cookie with the extension API. + * 5. Verify that the web page can see "edit_me" cookie (via document.cookie). + * 6. cookies.get: "edit_me" is still present. + * 7. cookies.remove: "edit_me" can be removed. + * 8. cookies.getAll: no cookies left. + */ + +const FIRST_DOMAIN = "first.example.com"; +const FIRST_DOMAIN_ETLD_PLUS_1 = "example.com"; +const FIRST_DOMAIN_ETLD_PLUS_MANY = "nested.under.first.example.com"; +const THIRD_PARTY_DOMAIN = "third.example.net"; +const server = createHttpServer({ + hosts: [FIRST_DOMAIN, FIRST_DOMAIN_ETLD_PLUS_MANY, THIRD_PARTY_DOMAIN], +}); +const LOCAL_IP_AND_PORT = `127.0.0.1:${server.identity.primaryPort}`; + +server.registerPathHandler("/top", (request, response) => { + response.setHeader("Set-Cookie", `delete_me=top; SameSite=none`); + response.setHeader("Set-Cookie", `edit_me=top; SameSite=none`, true); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + `<!DOCTYPE html><iframe src="//third.example.net/framed"></iframe>` + ); +}); +server.registerPathHandler("/framed", (request, response) => { + response.setHeader("Set-Cookie", `delete_me=frame; SameSite=none`); + response.setHeader("Set-Cookie", `edit_me=frame; SameSite=none`, true); +}); + +// Background script of the extension that drives the test. +// It first waits for the content scripts in /top and /framed to connect, +// in order to verify that cookie operations by the extension API are reflected +// to the web page (verified through document.cookie from the content script). +function backgroundScript() { + let portsByDomain = new Map(); + + async function getDocumentCookies(port) { + return new Promise(resolve => { + port.onMessage.addListener(function listener(cookieString) { + port.onMessage.removeListener(listener); + resolve(cookieString); + }); + port.postMessage("get_cookies"); + }); + } + + // Stringify cookie identifier for comparisons in assertions. + function stringifyCookie(cookie) { + if (!cookie) { + return "COOKIE MISSING"; + } + let domain = cookie.domain; + if (!domain) { + // The return value of `cookies.remove` has a URL instead of a domain. + domain = new URL(cookie.url).hostname; + } + return `${cookie.name} domain=${domain} firstPartyDomain=${ + cookie.firstPartyDomain + } partitionKey=${JSON.stringify(cookie.partitionKey)}`; + } + function stringifyCookies(cookies) { + return cookies + .map(stringifyCookie) + .sort() + .join(" , "); + } + + // detailsIn may have partitionKey and firstPartyDomain attributes. + // expectedOut has partitionKey and firstPartyDomain attributes. + async function runTestCase({ domain, detailsIn, expectedOut }) { + const port = portsByDomain.get(domain); + browser.test.assertTrue(port, `Got port to document for ${domain}`); + + let allCookies = await browser.cookies.getAll({ + domain, + firstPartyDomain: null, + partitionKey: {}, + }); + + let allCookiesWithFPD = await browser.cookies.getAll({ + domain, + ...detailsIn, + }); + browser.test.assertEq( + stringifyCookies(allCookies), + stringifyCookies(allCookiesWithFPD), + "cookies.getAll returns consistent results" + ); + + for (let [key, expectedValue] of Object.entries(expectedOut)) { + expectedValue = JSON.stringify(expectedValue); + browser.test.assertTrue( + allCookies.every(c => JSON.stringify(c[key]) === expectedValue), + `All ${allCookies.length} cookies have ${key}=${expectedValue}` + ); + } + + // delete_me: get, remove, get. + const cookieToDelete = { + url: `http://${domain}/`, + name: "delete_me", + ...detailsIn, + }; + const deletedCookie = { + ...cookieToDelete, + ...expectedOut, + }; + browser.test.assertEq( + stringifyCookie(deletedCookie), + stringifyCookie(await browser.cookies.get(cookieToDelete)), + "delete_me cookie exists before removal" + ); + browser.test.assertEq( + stringifyCookie(deletedCookie), + stringifyCookie(await browser.cookies.remove(cookieToDelete)), + "delete_me cookie has been removed by cookies.remove" + ); + browser.test.assertEq( + null, + await browser.cookies.get(cookieToDelete), + "delete_me cookie does not exist any more" + ); + + // edit_me: set, retrieve via document.cookie + const cookieToEdit = { + url: `http://${domain}/`, + name: "edit_me", + ...detailsIn, + }; + const editedCookie = await browser.cookies.set({ + ...cookieToEdit, + value: `new_value_${domain}`, + }); + browser.test.assertEq( + stringifyCookie({ ...cookieToEdit, ...expectedOut }), + stringifyCookie(editedCookie), + "edit_me cookie updated" + ); + browser.test.assertEq( + await getDocumentCookies(port), + `edit_me=new_value_${domain}`, + "Expected cookies after removing and editing a cookie" + ); + + // edit_me: get, remove, getAll. + browser.test.assertEq( + stringifyCookie(editedCookie), + stringifyCookie(await browser.cookies.get(cookieToEdit)), + "edit_me cookie still exists" + ); + await browser.cookies.remove(cookieToEdit); + let allCookiesAtEnd = await browser.cookies.getAll({ + domain, + firstPartyDomain: null, + partitionKey: {}, + }); + browser.test.assertEq( + "[]", + JSON.stringify(allCookiesAtEnd), + "No cookies left" + ); + } + + let resolveTestReady; + let testReadyPromise = new Promise(resolve => { + resolveTestReady = resolve; + }); + + browser.test.onMessage.addListener(async (msg, testCase) => { + await testReadyPromise; + browser.test.assertEq("runTest", msg, `Starting: ${testCase.description}`); + try { + await runTestCase(testCase); + } catch (e) { + browser.test.fail(`Unexpected error: ${e} :: ${e.stack}`); + } + browser.test.sendMessage("runTest_done"); + }); + + // cookie-checker-contentscript.js will connect. + browser.runtime.onConnect.addListener(port => { + portsByDomain.set(port.name, port); + browser.test.log(`Got port #${portsByDomain.size} ${port.name}`); + if (portsByDomain.size === 2) { + // The top document and the embedded frame has loaded and the + // content script that we use to read cookies is connected. + // The test can now start. + resolveTestReady(); + } + }); +} + +// The primary purpose of this test is to verify that the cookies API can read +// and write cookies that are actually in use by the web page. +async function testCookiesAPI({ testCases, topDomain = FIRST_DOMAIN }) { + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: [ + "cookies", + // Remove port to work around bug 1350523. + `*://${topDomain.replace(/:\d+$/, "")}/*`, + `*://${THIRD_PARTY_DOMAIN}/*`, + ], + content_scripts: [ + { + js: ["cookie-checker-contentscript.js"], + matches: [ + // Remove port to work around bug 1362809. + `*://${topDomain.replace(/:\d+$/, "")}/top`, + `*://${THIRD_PARTY_DOMAIN}/framed`, + ], + all_frames: true, + run_at: "document_end", + }, + ], + }, + files: { + "cookie-checker-contentscript.js": () => { + const port = browser.runtime.connect({ name: location.hostname }); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "get_cookies", "Expected port message"); + port.postMessage(document.cookie); + }); + }, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://${topDomain}/top` + ); + for (let testCase of testCases) { + info(`Running test case: ${testCase.description}`); + extension.sendMessage("runTest", testCase); + await extension.awaitMessage("runTest_done"); + } + await contentPage.close(); + await extension.unload(); +} + +add_task(async function setup() { + // SameSite=none is needed to set cookies in third-party contexts. + // SameSite=none usually requires Secure, but the test server doesn't support + // https, so disable the Secure requirement for SameSite=none. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); +}); + +add_task(async function test_no_partitioning() { + const testCases = [ + { + description: "first-party cookies without any partitioning", + domain: FIRST_DOMAIN, + detailsIn: { + firstPartyDomain: "", + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies without any partitioning", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + // Without (d)FPI, firstPartyDomain and partitionKey are optional. + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + ]; + await runWithPrefs( + // dFPI is enabled by default on Nightly, disable it. + [["network.cookie.cookieBehavior", 4]], + () => testCookiesAPI({ testCases }) + ); +}); + +add_task(async function test_firstPartyIsolate() { + const testCases = [ + { + description: "first-party cookies with FPI", + domain: FIRST_DOMAIN, + detailsIn: { + firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1, + }, + expectedOut: { + firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1, + partitionKey: null, + }, + }, + { + description: "third-party cookies with FPI", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1, + }, + expectedOut: { + firstPartyDomain: FIRST_DOMAIN_ETLD_PLUS_1, + partitionKey: null, + }, + }, + ]; + await runWithPrefs( + [ + // FPI is mutually exclusive with dFPI. Disable dFPI. + ["network.cookie.cookieBehavior", 4], + ["privacy.firstparty.isolate", true], + ], + () => testCookiesAPI({ testCases }) + ); +}); + +add_task(async function test_dfpi() { + const testCases = [ + { + description: "first-party cookies with dFPI", + domain: FIRST_DOMAIN, + detailsIn: { + // partitionKey is optional and expected to default to unpartitioned. + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies with dFPI", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + }, + ]; + await runWithPrefs( + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + [["network.cookie.cookieBehavior", 5]], + () => testCookiesAPI({ testCases }) + ); +}); + +add_task(async function test_dfpi_with_ip_and_port() { + const testCases = [ + { + description: "first-party cookies for IP with port", + domain: "127.0.0.1", + detailsIn: { + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies for IP with port", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + partitionKey: { topLevelSite: `http://${LOCAL_IP_AND_PORT}` }, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: { topLevelSite: `http://${LOCAL_IP_AND_PORT}` }, + }, + }, + ]; + await runWithPrefs( + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + [["network.cookie.cookieBehavior", 5]], + () => testCookiesAPI({ testCases, topDomain: LOCAL_IP_AND_PORT }) + ); +}); + +add_task(async function test_dfpi_with_nested_subdomains() { + const testCases = [ + { + description: "first-party cookies with DFPI at eTLD+many", + domain: FIRST_DOMAIN_ETLD_PLUS_MANY, + detailsIn: { + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies for first party with eTLD+many", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + // Partitioned cookies are keyed by eTLD+1, so even if eTLD+many is + // passed, then eTLD+1 is stored (and returned). + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_MANY}` }, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + }, + ]; + await runWithPrefs( + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + [["network.cookie.cookieBehavior", 5]], + () => testCookiesAPI({ testCases, topDomain: FIRST_DOMAIN_ETLD_PLUS_MANY }) + ); +}); + +add_task(async function test_dfpi_with_non_default_use_site() { + // privacy.dynamic_firstparty.use_site is a pref that can be used to toggle + // the internal representation of partitionKey. True (default) means keyed + // by site (scheme, host, port); false means keyed by host only. + const testCases = [ + { + description: "first-party cookies with dFPI and use_site=false", + domain: FIRST_DOMAIN, + detailsIn: { + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies with dFPI and use_site=false", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + partitionKey: { topLevelSite: `http://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + expectedOut: { + firstPartyDomain: "", + // When use_site=false, the scheme is not stored, and the + // implementation just prepends "https" as a dummy scheme. + partitionKey: { topLevelSite: `https://${FIRST_DOMAIN_ETLD_PLUS_1}` }, + }, + }, + ]; + await runWithPrefs( + [ + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + ["network.cookie.cookieBehavior", 5], + ["privacy.dynamic_firstparty.use_site", false], + ], + () => testCookiesAPI({ testCases }) + ); +}); +add_task(async function test_dfpi_with_ip_and_port_and_non_default_use_site() { + // privacy.dynamic_firstparty.use_site is a pref that can be used to toggle + // the internal representation of partitionKey. True (default) means keyed + // by site (scheme, host, port); false means keyed by host only. + const testCases = [ + { + description: "first-party cookies for IP:port with dFPI+use_site=false", + domain: "127.0.0.1", + detailsIn: { + partitionKey: null, + }, + expectedOut: { + firstPartyDomain: "", + partitionKey: null, + }, + }, + { + description: "third-party cookies for IP:port with dFPI+use_site=false", + domain: THIRD_PARTY_DOMAIN, + detailsIn: { + // When use_site=false, the scheme is not stored in the internal + // representation of the partitionKey. So even though the web page + // creates the cookie at HTTP, the cookies are still detected when + // "https" is used. + partitionKey: { topLevelSite: `https://${LOCAL_IP_AND_PORT}` }, + }, + expectedOut: { + firstPartyDomain: "", + // When use_site=false, the scheme and port are not stored. + // "https" is used as a dummy scheme, and the port is not used. + partitionKey: { topLevelSite: "https://127.0.0.1" }, + }, + }, + ]; + await runWithPrefs( + [ + // Enable dFPI; 5 = BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN. + ["network.cookie.cookieBehavior", 5], + ["privacy.dynamic_firstparty.use_site", false], + ], + () => testCookiesAPI({ testCases, topDomain: LOCAL_IP_AND_PORT }) + ); +}); + +add_task(async function dfpi_invalid_partitionKey() { + AddonTestUtils.init(globalThis); + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" + ); + // The test below uses the browser.privacy API, which relies on + // ExtensionSettingsStore, which in turn depends on AddonManager. + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["cookies", "*://example.com/*", "privacy"], + }, + async background() { + const url = "http://example.com/"; + const name = "dfpi_invalid_partitionKey_dummy_name"; + const value = "1"; + + // Shorthands to minimize boilerplate. + const set = d => browser.cookies.set({ url, name, value, ...d }); + const remove = d => browser.cookies.remove({ url, name, ...d }); + const get = d => browser.cookies.get({ url, name, ...d }); + const getAll = d => browser.cookies.getAll(d); + + await browser.test.assertRejects( + set({ partitionKey: { topLevelSite: "example.net" } }), + /Invalid value for 'partitionKey' attribute/, + "partitionKey must be a URL, not a domain" + ); + await browser.test.assertRejects( + set({ partitionKey: { topLevelSite: "chrome://foo" } }), + /Invalid value for 'partitionKey' attribute/, + "partitionKey cannot be the chrome:-scheme (canonicalization fails)" + ); + await browser.test.assertRejects( + set({ partitionKey: { topLevelSite: "chrome://foo/foo/foo" } }), + /Invalid value for 'partitionKey' attribute/, + "partitionKey cannot be the chrome:-scheme (canonicalization passes)" + ); + await browser.test.assertRejects( + set({ partitionKey: { topLevelSite: "http://[]:" } }), + /Invalid value for 'partitionKey' attribute/, + "partitionKey must be a valid URL" + ); + + browser.test.assertThrows( + () => get({ partitionKey: "" }), + /Error processing partitionKey: Expected object instead of ""/, + "cookies.get should reject invalid partitionKey (string)" + ); + browser.test.assertThrows( + () => get({ partitionKey: { topLevelSite: "http://x", badkey: 0 } }), + /Error processing partitionKey: Unexpected property "badkey"/, + "cookies.get should reject unsupported keys in partitionKey" + ); + await browser.test.assertRejects( + remove({ partitionKey: { topLevelSite: "invalid" } }), + /Invalid value for 'partitionKey' attribute/, + "cookies.remove should reject invalid partitionKey.topLevelSite" + ); + await browser.test.assertRejects( + get({ partitionKey: { topLevelSite: "invalid" } }), + /Invalid value for 'partitionKey' attribute/, + "cookies.get should reject invalid partitionKey.topLevelSite" + ); + await browser.test.assertRejects( + getAll({ partitionKey: { topLevelSite: "invalid" } }), + /Invalid value for 'partitionKey' attribute/, + "cookies.getAll should reject invalid partitionKey.topLevelSite" + ); + + // firstPartyDomain and partitionKey are mutually exclusive, because + // FPI and dFPI are mutually exclusive. + await browser.test.assertRejects( + set({ firstPartyDomain: "example.net", partitionKey: {} }), + /Partitioned cookies cannot have a 'firstPartyDomain' attribute./, + "partitionKey and firstPartyDomain cannot both be non-empty" + ); + + // On Nightly, dFPI is enabled by default. We have to disable it first, + // before we can enable FPI. Otherwise we would get error: + // Can't enable firstPartyIsolate when cookieBehavior is 'reject_trackers_and_partition_foreign' + await browser.privacy.websites.cookieConfig.set({ + value: { behavior: "reject_trackers" }, + }); + await browser.privacy.websites.firstPartyIsolate.set({ + value: true, + }); + + // FPI and dFPI are mutually exclusive. FPI is documented to require the + // firstPartyDomain attribute, let's verify that, despite it being + // technically possible to support both attributes. + for (let cookiesMethod of [get, getAll, remove, set]) { + await browser.test.assertRejects( + cookiesMethod({ partitionKey: { topLevelSite: url } }), + /First-Party Isolation is enabled, but the required 'firstPartyDomain' attribute was not set./, + `cookies.${cookiesMethod.name} requires firstPartyDomain when FPI is enabled` + ); + } + + // The pref changes above (to dFPI/FPI) via the browser.privacy API will + // be undone when the extension unloads. + + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); + + await AddonTestUtils.promiseShutdownManager(); +}); + +add_task(async function dfpi_moz_extension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies", "*://example.com/*"], + }, + async background() { + let cookie = await browser.cookies.set({ + url: "http://example.com/", + name: "moz_ext_party", + value: "1", + // moz-extension: URL is passed here, in an attempt to mark the cookie + // as part of the "moz-extension:"-partition. Below we will expect "" + // because the dFPI implementation treats "moz-extension" as + // unpartitioned, see + // https://searchfox.org/mozilla-central/rev/ac7da6c7306d86e2f86a302ce1e170ad54b3c1fe/caps/OriginAttributes.cpp#79-82 + partitionKey: { topLevelSite: browser.runtime.getURL("/") }, + }); + browser.test.assertEq( + null, + cookie.partitionKey, + "Cookies in moz-extension:-URL are unpartitioned" + ); + let deletedCookie = await browser.cookies.remove({ + url: "http://example.com/", + name: "moz_ext_party", + partitionKey: { topLevelSite: "moz-extension://ignoreme" }, + }); + browser.test.assertEq( + null, + deletedCookie.partitionKey, + "moz-extension:-partition key is treated as unpartitioned" + ); + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +add_task(async function dfpi_about_scheme_as_partitionKey() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies", "*://example.com/*"], + }, + async background() { + let cookie = await browser.cookies.set({ + url: "http://example.com/", + name: "moz_ext_party", + value: "1", + partitionKey: { topLevelSite: "about:blank" }, + }); + // It doesn't really make sense to partition in `about:blank` (since it + // cannot really be a first party), but for completeness of test coverage + // we also check that the use of an about:-scheme results in predictable + // behavior. The weird "about://"-URL below is the serialization of the + // internal value of the partitionKey attribute: + // https://searchfox.org/mozilla-central/rev/ac7da6c7306d86e2f86a302ce1e170ad54b3c1fe/caps/OriginAttributes.cpp#73-77 + browser.test.assertEq( + "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla", + cookie.partitionKey.topLevelSite, + "An URL-like representation of the internal about:-format is returned" + ); + let deletedCookie = await browser.cookies.remove({ + url: "http://example.com/", + name: "moz_ext_party", + partitionKey: { + topLevelSite: + "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla", + }, + }); + browser.test.assertEq( + "about://about.ef2a7dd5-93bc-417f-a698-142c3116864f.mozilla", + deletedCookie.partitionKey.topLevelSite, + "Cookie can be deleted via the dummy about:-scheme" + ); + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +// Same-site frames are expected to be unpartitioned. +// The cookies API can receive partitionKey and url that are same-site. While +// such cookies won't be sent to websites in practice, we do want to verify that +// the behavior is predictable. +add_task(async function test_url_is_same_site_as_partitionKey() { + // This loads a page with a frame at third.example.net (= THIRD_PARTY_DOMAIN). + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://${THIRD_PARTY_DOMAIN}/top` + ); + await contentPage.close(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies", "*://third.example.net/"], + }, + async background() { + // Retrieve all cookies, partitioned and unpartitioned. We expect only + // unpartitioned cookies at first because the top frame and the child + // frame have the same origin. + let initialCookies = await browser.cookies.getAll({ partitionKey: {} }); + browser.test.assertEq( + "delete_me=frame,edit_me=frame", + initialCookies.map(c => `${c.name}=${c.value}`).join(), + "Same-site frames are in unpartitioned storage; /frame overwrites /top" + ); + browser.test.assertTrue( + await browser.cookies.remove({ + url: "https://third.example.net/", + name: "delete_me", + }), + "Removed unpartitioned cookie" + ); + browser.test.assertEq( + "[null,null]", + JSON.stringify(initialCookies.map(c => c.partitionKey)), + "Cookies in same-site/same-origin frames are not partitioned" + ); + + // We only have one unpartitioned cookie (edit_cookie) left. + + // Add new cookie whose partitionKey is same-site relative to url. + let newCookie = await browser.cookies.set({ + url: "http://third.example.net/", + name: "edit_me", + value: "url_is_partitionKey_eTLD+2", + partitionKey: { topLevelSite: "http://third.example.net" }, + }); + browser.test.assertEq( + "http://example.net", + newCookie.partitionKey.topLevelSite, + "Created cookie with partitionKey=url; eTLD+2 is normalized as eTLD+1" + ); + + browser.test.assertTrue( + await browser.cookies.remove({ + url: "http://third.example.net/", + name: "edit_me", + partitionKey: {}, + }), + "Removed unpartitioned cookie when partitionKey: {} is used" + ); + + browser.test.assertEq( + null, + await browser.cookies.remove({ + url: "http://third.example.net/", + name: "edit_me", + partitionKey: {}, + }), + "No more unpartitioned cookies to remove" + ); + + browser.test.assertTrue( + await browser.cookies.remove({ + url: "http://third.example.net/", + name: "edit_me", + partitionKey: { topLevelSite: "http://example.net" }, + }), + "Removed partitioned cookie when partitionKey is passed" + ); + + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +add_task(async function test_getAll_partitionKey() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies", "*://third.example.net/"], + }, + async background() { + const url = "http://third.example.net"; + const name = "test_url_is_identical_to_partitionKey"; + const partitionKey = { topLevelSite: "http://example.com" }; + const firstPartyDomain = "example.net"; + + // Create non-partitioned cookie, create partitioned cookie. + await browser.cookies.set({ url, name, value: "no_partition" }); + await browser.cookies.set({ url, name, value: "fpd", firstPartyDomain }); + await browser.cookies.set({ url, name, partitionKey, value: "party" }); + // partitionKey + firstPartyDomain was tested in dfpi_invalid_partitionKey + + async function getAllValues(details) { + let cookies = await browser.cookies.getAll(details); + let values = cookies.map(c => c.value); + return values.sort().join(); // Serialize for use with assertEq. + } + + browser.test.assertEq( + "no_partition", + await getAllValues({}), + "getAll() returns unpartitioned by default" + ); + + browser.test.assertEq( + "no_partition,party", + await getAllValues({ partitionKey: {} }), + "getAll() with partitionKey: {} returns all cookies" + ); + + browser.test.assertEq( + "party", + await getAllValues({ partitionKey }), + "getAll() with specific partitionKey returns partitionKey cookies only" + ); + + browser.test.assertEq( + "", + await getAllValues({ partitionKey: { topLevelSite: url } }), + "getAll() with partitionKey set to cookie URL does not match anything" + ); + + browser.test.assertEq( + "", + await getAllValues({ partitionKey, firstPartyDomain }), + "getAll() with non-empty partitionKey and firstPartyDomain does not match anything" + ); + browser.test.assertEq( + "fpd", + await getAllValues({ partitionKey: {}, firstPartyDomain }), + "getAll() with empty partitionKey and firstPartyDomain matches fpd" + ); + + browser.test.assertEq( + "fpd,no_partition,party", + await getAllValues({ partitionKey: {}, firstPartyDomain: null }), + "getAll() with empty partitionKey and firstPartyDomain:null matches everything" + ); + + await browser.cookies.remove({ url, name }); + await browser.cookies.remove({ url, name, firstPartyDomain }); + await browser.cookies.remove({ url, name, partitionKey }); + + browser.test.sendMessage("test_done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test_done"); + await extension.unload(); +}); + +add_task(async function no_unexpected_cookies_at_end_of_test() { + let results = []; + for (const cookie of Services.cookies.cookies) { + results.push({ + name: cookie.name, + value: cookie.value, + host: cookie.host, + originAttributes: cookie.originAttributes, + }); + } + Assert.deepEqual(results, [], "Test should not leave any cookies"); +}); 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..618ed820d4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js @@ -0,0 +1,114 @@ +"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() { + // Bug 1617611 - Fix all the tests broken by "cookies SameSite=Lax by default" + Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", false); + + 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(); + + Services.prefs.clearUserPref("network.cookie.sameSite.laxByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js b/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js new file mode 100644 index 0000000000..5463cec63a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cors_mozextension.js @@ -0,0 +1,220 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.com", "x.example.com"], +}); +server.registerPathHandler("/dummy", (req, res) => { + res.write("dummy"); +}); +server.registerPathHandler("/redir", (req, res) => { + res.setStatusLine(req.httpVersion, 302, "Found"); + res.setHeader("Access-Control-Allow-Origin", "http://example.com"); + res.setHeader("Access-Control-Allow-Credentials", "true"); + res.setHeader("Location", new URLSearchParams(req.queryString).get("url")); +}); + +add_task(async function load_moz_extension_with_and_without_cors() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + web_accessible_resources: ["ok.js"], + }, + files: { + "ok.js": "window.status = 'loaded';", + "deny.js": "window.status = 'unexpected load'", + }, + }); + await extension.startup(); + const EXT_BASE_URL = `moz-extension://${extension.uuid}`; + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await contentPage.spawn(EXT_BASE_URL, async EXT_BASE_URL => { + const { document, window } = this.content; + async function checkScriptLoad({ setupScript, expectLoad, description }) { + const scriptElem = document.createElement("script"); + setupScript(scriptElem); + return new Promise(resolve => { + window.status = "initial"; + scriptElem.onload = () => { + Assert.equal(window.status, "loaded", "Script executed upon load"); + Assert.ok(expectLoad, `Script loaded - ${description}`); + resolve(); + }; + scriptElem.onerror = () => { + Assert.equal(window.status, "initial", "not executed upon error"); + Assert.ok(!expectLoad, `Script not loaded - ${description}`); + resolve(); + }; + document.head.append(scriptElem); + }); + } + + function sameOriginRedirectUrl(url) { + return `http://example.com/redir?url=` + encodeURIComponent(url); + } + function crossOriginRedirectUrl(url) { + return `http://x.example.com/redir?url=` + encodeURIComponent(url); + } + + // Direct load of web-accessible extension script. + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + }, + expectLoad: true, + description: "web-accessible script, plain load", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: true, + description: "web-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + scriptElem.crossOrigin = "use-credentials"; + }, + expectLoad: true, + description: "web-accessible script, cors+credentials", + }); + + // Load of web-accessible extension scripts, after same-origin redirect. + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + }, + expectLoad: true, + description: "same-origin redirect to web-accessible script, plain load", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: true, + description: "same-origin redirect to web-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "use-credentials"; + }, + expectLoad: true, + description: + "same-origin redirect to web-accessible script, cors+credentials", + }); + + // Load of web-accessible extension scripts, after cross-origin redirect. + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + }, + expectLoad: true, + description: "cross-origin redirect to web-accessible script, plain load", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: true, + description: "cross-origin redirect to web-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "use-credentials"; + }, + expectLoad: true, + description: + "cross-origin redirect to web-accessible script, cors+credentials", + }); + + // Various loads of non-web-accessible extension script. + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/deny.js`; + }, + expectLoad: false, + description: "non-accessible script, plain load", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/deny.js`; + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: false, + description: "non-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/deny.js`); + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: false, + description: "same-origin redirect to non-accessible script, cors", + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/deny.js`); + scriptElem.crossOrigin = "anonymous"; + }, + expectLoad: false, + description: "cross-origin redirect to non-accessible script, cors", + }); + + // Sub-resource integrity usually requires CORS. Verify that web-accessible + // extension resources are still subjected to SRI. + const sriHashOkJs = // SRI hash for "window.status = 'loaded';" (=ok.js). + "sha384-EAofaAZpgy6JshegITJJHeE3ROzn9ngGw1GAuuzjSJV1c/YS9PLvHMt9oh4RovrI"; + + async function testSRI({ integrityMatches }) { + const integrity = integrityMatches ? sriHashOkJs : "sha384-bad-sri-hash"; + const sriDescription = integrityMatches + ? "web-accessible script, good sri, " + : "web-accessible script, sri not matching, "; + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + scriptElem.integrity = integrity; + }, + expectLoad: integrityMatches, + description: `${sriDescription} no cors, plain load`, + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = `${EXT_BASE_URL}/ok.js`; + scriptElem.crossOrigin = "anonymous"; + scriptElem.integrity = integrity; + }, + expectLoad: integrityMatches, + description: `${sriDescription} cors, plain load`, + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = sameOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "anonymous"; + scriptElem.integrity = integrity; + }, + expectLoad: integrityMatches, + description: `${sriDescription} cors, same-origin redirect`, + }); + await checkScriptLoad({ + setupScript(scriptElem) { + scriptElem.src = crossOriginRedirectUrl(`${EXT_BASE_URL}/ok.js`); + scriptElem.crossOrigin = "anonymous"; + scriptElem.integrity = integrity; + }, + expectLoad: integrityMatches, + description: `${sriDescription} cors, cross-origin redirect`, + }); + } + await testSRI({ integrityMatches: true }); + await testSRI({ integrityMatches: false }); + }); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js b/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js new file mode 100644 index 0000000000..ae931dfe06 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_csp_frame_ancestors.js @@ -0,0 +1,221 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const server = createHttpServer({ hosts: ["example.com", "example.net"] }); +server.registerPathHandler("/parent.html", (request, response) => { + let frameUrl = new URLSearchParams(request.queryString).get("iframe_src"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write(`<!DOCTYPE html><iframe src="${frameUrl}"></iframe>`); +}); + +// Loads an extension frame as a frame at ancestorOrigins[0], which in turn is +// a child of ancestorOrigins[1], etc. +// The frame should either load successfully, or trigger exactly one failure due +// to one of the ancestorOrigins being blocked by the content_security_policy. +async function checkExtensionLoadInFrame({ + ancestorOrigins, + content_security_policy, + expectLoad, +}) { + const extensionData = { + manifest: { + content_security_policy, + web_accessible_resources: ["parent.html", "frame.html"], + }, + files: { + "frame.html": `<!DOCTYPE html><script src="frame.js"></script>`, + "frame.js": () => { + browser.test.sendMessage("frame_load_completed"); + }, + "parent.html": `<!DOCTYPE html><body><script src="parent.js"></script>`, + "parent.js": () => { + let iframe = document.createElement("iframe"); + iframe.src = new URLSearchParams(location.search).get("iframe_src"); + document.body.append(iframe); + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + const EXTENSION_FRAME_URL = `moz-extension://${extension.uuid}/frame.html`; + + // ancestorOrigins is a list of origins, from the parent up to the top frame. + let topUrl = EXTENSION_FRAME_URL; + for (let origin of ancestorOrigins) { + if (origin === "EXTENSION_ORIGIN") { + origin = `moz-extension://${extension.uuid}`; + } + // origin is either the origin for |server| or the test extension. Both + // endpoints serve a page at parent.html that embeds iframe_src. + topUrl = `${origin}/parent.html?iframe_src=${encodeURIComponent(topUrl)}`; + } + + let cspViolationObserver; + let cspViolationCount = 0; + let frameLoadedCount = 0; + let frameLoadOrFailedPromise = new Promise(resolve => { + extension.onMessage("frame_load_completed", () => { + ++frameLoadedCount; + resolve(); + }); + cspViolationObserver = { + observe(subject, topic, data) { + ++cspViolationCount; + Assert.equal(data, "frame-ancestors", "CSP violation directive"); + resolve(); + }, + }; + Services.obs.addObserver(cspViolationObserver, "csp-on-violate-policy"); + }); + + const contentPage = await ExtensionTestUtils.loadContentPage(topUrl); + + // Firstly, wait for the frame load to either complete or fail. + await frameLoadOrFailedPromise; + + // Secondly, do a round trip to the content process to make sure that any + // unexpected extra load/failures are observed. This is necessary, because + // the "csp-on-violate-policy" notification is triggered from the parent, + // while it may be possible for the load to continue in the child anyway. + // + // And while we are at it, this verifies that the CSP does not block regular + // reads of a file that's part of web_accessible_resources. For comparable + // results, the load should ideally happen in the parent of the extension + // frame, but contentPage.fetch only works in the top frame, so this does not + // work perfectly in case ancestorOrigins.length > 1. + // But that is OK, as we mainly care about unexpected frame loads/failures. + equal( + await contentPage.fetch(EXTENSION_FRAME_URL), + extensionData.files["frame.html"], + "web-accessible extension resource can still be read with fetch" + ); + + // Finally, clean up. + Services.obs.removeObserver(cspViolationObserver, "csp-on-violate-policy"); + await contentPage.close(); + await extension.unload(); + + if (expectLoad) { + equal(cspViolationCount, 0, "Expected no CSP violations"); + equal( + frameLoadedCount, + 1, + `Frame should accept ancestors (${ancestorOrigins}) in CSP: ${content_security_policy}` + ); + } else { + equal(cspViolationCount, 1, "Expected CSP violation count"); + equal( + frameLoadedCount, + 0, + `Frame should reject one of the ancestors (${ancestorOrigins}) in CSP: ${content_security_policy}` + ); + } +} + +add_task(async function test_frame_ancestors_missing_allows_self() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["EXTENSION_ORIGIN"], + content_security_policy: "default-src 'self'", // missing frame-ancestors. + expectLoad: true, // an extension can embed itself by default. + }); +}); + +add_task(async function test_frame_ancestors_self_allows_self() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["EXTENSION_ORIGIN"], + content_security_policy: "default-src 'self'; frame-ancestors 'self'", + expectLoad: true, + }); +}); + +add_task(async function test_frame_ancestors_none_blocks_self() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["EXTENSION_ORIGIN"], + content_security_policy: "default-src 'self'; frame-ancestors", + expectLoad: false, // frame-ancestors 'none' blocks extension frame. + }); +}); + +add_task(async function test_frame_ancestors_missing_allowed_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: "default-src 'self'", // missing frame-ancestors + expectLoad: true, // Web page can embed web-accessible extension frames. + }); +}); + +add_task(async function test_frame_ancestors_self_blocked_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: "default-src 'self'; frame-ancestors 'self'", + expectLoad: false, + }); +}); + +add_task(async function test_frame_ancestors_scheme_allowed_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: "default-src 'self'; frame-ancestors http:", + expectLoad: true, + }); +}); + +add_task(async function test_frame_ancestors_origin_allowed_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: + "default-src 'self'; frame-ancestors http://example.com", + expectLoad: true, + }); +}); + +add_task(async function test_frame_ancestors_mismatch_blocked_in_web_page() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com"], + content_security_policy: + "default-src 'self'; frame-ancestors http://not.example.com", + expectLoad: false, + }); +}); + +add_task(async function test_frame_ancestors_top_mismatch_blocked() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com", "http://example.net"], + content_security_policy: + "default-src 'self'; frame-ancestors http://example.com", + // example.com is allowed, but the top origin (example.net) is rejected. + expectLoad: false, + }); +}); + +add_task(async function test_frame_ancestors_parent_mismatch_blocked() { + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.net", "http://example.com"], + content_security_policy: + "default-src 'self'; frame-ancestors http://example.com", + // example.com is allowed, but the parent origin (example.net) is rejected. + expectLoad: false, + }); +}); + +add_task(async function test_frame_ancestors_middle_rejected() { + if (!WebExtensionPolicy.useRemoteWebExtensions) { + // This test load http://example.com in an extension page, which fails if + // extensions run in the parent process. This is not a default config on + // desktop, but see https://bugzilla.mozilla.org/show_bug.cgi?id=1724099 + info("Web pages cannot be loaded in extension page without OOP extensions"); + return; + } + await checkExtensionLoadInFrame({ + ancestorOrigins: ["http://example.com", "EXTENSION_ORIGIN"], + content_security_policy: + "default-src 'self'; frame-src http: 'self'; frame-ancestors 'self'", + // Although the top frame has the same origin as the extension, the load + // should be rejected anyway because there is a non-allowlisted origin in + // the middle (child of top frame, parent of extension frame). + expectLoad: false, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js b/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js new file mode 100644 index 0000000000..6780293f04 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_csp_upgrade_requests.js @@ -0,0 +1,74 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (req, res) => { + res.write("ok"); +}); + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); +}); + +add_task(async function test_csp_upgrade() { + async function background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + details.url, + "https://example.com/", + "request upgraded and sent" + ); + browser.test.notifyPass(); + return { cancel: true }; + }, + { + urls: ["https://example.com/*"], + }, + ["blocking"] + ); + + await browser.test.assertRejects( + fetch("http://example.com/"), + "NetworkError when attempting to fetch resource.", + "request was upgraded" + ); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: ["webRequest", "webRequestBlocking"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_csp_noupgrade() { + async function background() { + let req = await fetch("http://example.com/"); + browser.test.assertEq( + req.url, + "http://example.com/", + "request not upgraded" + ); + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + 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..635cc63997 --- /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: { + browser_specific_settings: { + 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: { + browser_specific_settings: { + 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: { + browser_specific_settings: { + gecko: { + id: "test-addon-1@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + let anotherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + 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_dnr_allowAllRequests.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js new file mode 100644 index 0000000000..b98807b7dd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_allowAllRequests.js @@ -0,0 +1,96 @@ +"use strict"; + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +const server = createHttpServer({ + hosts: ["example.com", "example.net", "example.org"], +}); +server.registerPathHandler("/never_reached", (req, res) => { + Assert.ok(false, "Server should never have been reached"); +}); +server.registerPathHandler("/allowed", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + res.write("allowed"); +}); +server.registerPathHandler("/", (req, res) => { + res.write("Dummy page"); +}); + +add_task(async function allowAllRequests_allows_request() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + // allowAllRequests should take precedence over block. + { + id: 1, + condition: { resourceTypes: ["main_frame", "xmlhttprequest"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { resourceTypes: ["main_frame"] }, + action: { type: "allowAllRequests" }, + }, + { + id: 3, + priority: 2, + // Note: when not specified, main_frame is excluded by default. So + // when a main_frame request is triggered, only rules 1 and 2 match. + condition: { requestDomains: ["example.com"] }, + action: { type: "block" }, + }, + ], + }); + browser.test.sendMessage("dnr_registered"); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/" + ); + Assert.equal( + await contentPage.spawn(null, () => content.document.URL), + "http://example.com/", + "main_frame request should have been allowed by allowAllRequests" + ); + + async function checkCanFetch(url) { + return contentPage.spawn(url, async url => { + try { + await (await content.fetch(url)).text(); + return true; + } catch (e) { + return false; // NetworkError: blocked + } + }); + } + + Assert.equal( + await checkCanFetch("http://example.com/never_reached"), + false, + "should be blocked by DNR rule 3" + ); + Assert.equal( + await checkCanFetch("http://example.net/"), + // TODO bug 1797403: Fix expectation once allowAllRequests is implemented: + // true, + // "should not be blocked by block rule due to allowAllRequests rule" + false, + "is blocked because persistency of allowAllRequests is not yet implemented" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js new file mode 100644 index 0000000000..f01c8a1e7b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_api.js @@ -0,0 +1,256 @@ +"use strict"; + +AddonTestUtils.init(this); + +const PREF_DNR_FEEDBACK_DEFAULT_VALUE = Services.prefs.getBoolPref( + "extensions.dnr.feedback", + false +); + +async function testAvailability({ + allowDNRFeedback = false, + testExpectations, + ...extensionData +}) { + function background(testExpectations) { + let { + declarativeNetRequest_available = false, + testMatchOutcome_available = false, + } = testExpectations; + browser.test.assertEq( + declarativeNetRequest_available, + !!browser.declarativeNetRequest, + "declarativeNetRequest API namespace availability" + ); + browser.test.assertEq( + testMatchOutcome_available, + !!browser.declarativeNetRequest?.testMatchOutcome, + "declarativeNetRequest.testMatchOutcome availability" + ); + browser.test.sendMessage("done"); + } + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + manifest: { + manifest_version: 3, + ...extensionData.manifest, + }, + background: `(${background})(${JSON.stringify(testExpectations)});`, + }); + Services.prefs.setBoolPref("extensions.dnr.feedback", allowDNRFeedback); + try { + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + } finally { + Services.prefs.clearUserPref("extensions.dnr.feedback"); + } +} + +add_setup(async () => { + // TODO bug 1782685: Remove this check. + Assert.equal( + Services.prefs.getBoolPref("extensions.dnr.enabled", false), + false, + "DNR is disabled by default" + ); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); +}); + +// Verifies that DNR is disabled by default (until true in bug 1782685). +add_task( + { + pref_set: [["extensions.dnr.enabled", false]], + }, + async function dnr_disabled_by_default() { + let { messages } = await promiseConsoleOutput(async () => { + await testAvailability({ + allowDNRFeedback: PREF_DNR_FEEDBACK_DEFAULT_VALUE, + testExpectations: { + declarativeNetRequest_available: false, + }, + manifest: { + permissions: [ + "declarativeNetRequest", + "declarativeNetRequestFeedback", + "declarativeNetRequestWithHostAccess", + ], + }, + }); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: /Reading manifest: Invalid extension permission: declarativeNetRequest$/, + }, + { + message: /Reading manifest: Invalid extension permission: declarativeNetRequestFeedback/, + }, + { + message: /Reading manifest: Invalid extension permission: declarativeNetRequestWithHostAccess/, + }, + ], + }); + } +); + +add_task(async function dnr_feedback_apis_disabled_by_default() { + let { messages } = await promiseConsoleOutput(async () => { + await testAvailability({ + allowDNRFeedback: PREF_DNR_FEEDBACK_DEFAULT_VALUE, + testExpectations: { + declarativeNetRequest_available: true, + }, + manifest: { + permissions: [ + "declarativeNetRequest", + "declarativeNetRequestFeedback", + "declarativeNetRequestWithHostAccess", + ], + }, + }); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: /Reading manifest: Invalid extension permission: declarativeNetRequestFeedback/, + }, + ], + forbidden: [ + { + message: /Reading manifest: Invalid extension permission: declarativeNetRequest$/, + }, + { + message: /Reading manifest: Invalid extension permission: declarativeNetRequestWithHostAccess/, + }, + ], + }); +}); + +// TODO bug 1782685: Remove "min_manifest_version":3 from DNR permissions. +add_task(async function dnr_restricted_to_mv3() { + let { messages } = await promiseConsoleOutput(async () => { + // Manifest version-restricted permissions result in schema-generated + // warnings. Don't fail when the "unrecognized" permission appear, to allow + // us to check for warning log messages below. + ExtensionTestUtils.failOnSchemaWarnings(false); + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: false, + }, + manifest: { + manifest_version: 2, + permissions: [ + "declarativeNetRequest", + "declarativeNetRequestFeedback", + "declarativeNetRequestWithHostAccess", + ], + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: /Warning processing permissions: Error processing permissions.0: Value "declarativeNetRequest"/, + }, + { + message: /Warning processing permissions: Error processing permissions.1: Value "declarativeNetRequestFeedback"/, + }, + { + message: /Warning processing permissions: Error processing permissions.2: Value "declarativeNetRequestWithHostAccess"/, + }, + ], + }); +}); + +add_task(async function with_declarativeNetRequest_permission() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + // feature allowed, but missing declarativeNetRequestFeedback: + testMatchOutcome_available: false, + }, + manifest: { + permissions: ["declarativeNetRequest"], + }, + }); +}); + +add_task(async function with_declarativeNetRequestWithHostAccess_permission() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + // feature allowed, but missing declarativeNetRequestFeedback: + testMatchOutcome_available: false, + }, + manifest: { + permissions: ["declarativeNetRequestWithHostAccess"], + }, + }); +}); + +add_task(async function with_all_declarativeNetRequest_permissions() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + // feature allowed, but missing declarativeNetRequestFeedback: + testMatchOutcome_available: false, + }, + manifest: { + permissions: [ + "declarativeNetRequest", + "declarativeNetRequestWithHostAccess", + ], + }, + }); +}); + +add_task(async function no_declarativeNetRequest_permission() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + // Just declarativeNetRequestFeedback should not unlock the API. + declarativeNetRequest_available: false, + }, + manifest: { + permissions: ["declarativeNetRequestFeedback"], + }, + }); +}); + +add_task(async function with_declarativeNetRequestFeedback_permission() { + await testAvailability({ + allowDNRFeedback: true, + testExpectations: { + declarativeNetRequest_available: true, + // feature allowed, and all permissions specified: + testMatchOutcome_available: true, + }, + manifest: { + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); +}); + +add_task(async function declarativeNetRequestFeedback_without_feature() { + await testAvailability({ + allowDNRFeedback: false, + testExpectations: { + declarativeNetRequest_available: true, + // all permissions set, but DNR feedback feature not allowed. + testMatchOutcome_available: false, + }, + manifest: { + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js new file mode 100644 index 0000000000..e3b65ac721 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_dynamic_rules.js @@ -0,0 +1,870 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +Services.scriptloader.loadSubScript( + Services.io.newFileURI(do_get_file("head_dnr.js")).spec, + this +); + +const { promiseStartupManager, promiseRestartManager } = AddonTestUtils; + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); + + await promiseStartupManager(); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + + function serializeForLog(data) { + // JSON-stringify, but drop null values (replacing them with undefined + // causes JSON.stringify to drop them), so that optional keys with the null + // values are hidden. + let str = JSON.stringify(data, rep => rep ?? undefined); + return str; + } + + async function testInvalidRule(rule, expectedError, isSchemaError) { + if (isSchemaError) { + // Schema validation error = thrown error instead of a rejection. + browser.test.assertThrows( + () => dnr.updateDynamicRules({ addRules: [rule] }), + expectedError, + `Rule should be invalid (schema-validated): ${serializeForLog(rule)}` + ); + } else { + await browser.test.assertRejects( + dnr.updateDynamicRules({ addRules: [rule] }), + expectedError, + `Rule should be invalid: ${serializeForLog(rule)}` + ); + } + } + + Object.assign(dnrTestUtils, { + testInvalidRule, + serializeForLog, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ + background, + unloadTestAtEnd = true, + awaitFinish = false, +}) { + const testExtensionParams = { + background: `(${background})((${makeDnrTestUtils})())`, + useAddonManager: "permanent", + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + browser_specific_settings: { + gecko: { id: "test-dynamic-rules@test-extension" }, + }, + }, + }; + const extension = ExtensionTestUtils.loadExtension(testExtensionParams); + await extension.startup(); + if (awaitFinish) { + await extension.awaitFinish(); + } + if (unloadTestAtEnd) { + await extension.unload(); + } + return { extension, testExtensionParams }; +} + +function callTestMessageHandler(extension, testMessage, ...args) { + extension.sendMessage(testMessage, ...args); + return extension.awaitMessage(`${testMessage}:done`); +} + +add_task(async function test_dynamic_rule_registration() { + await runAsDNRExtension({ + background: async () => { + const dnr = browser.declarativeNetRequest; + + await dnr.updateDynamicRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + + const url = "https://example.com/some-dummy-url"; + const type = "font"; + browser.test.assertDeepEq( + { matchedRules: [{ ruleId: 1, rulesetId: "_dynamic" }] }, + await dnr.testMatchOutcome({ url, type }), + "Dynamic rule matched after registration" + ); + + await dnr.updateDynamicRules({ + removeRuleIds: [ + 1, + 1234567890, // Invalid rules should be ignored. + ], + addRules: [{ id: 2, condition: {}, action: { type: "block" } }], + }); + browser.test.assertDeepEq( + { matchedRules: [{ ruleId: 2, rulesetId: "_dynamic" }] }, + await dnr.testMatchOutcome({ url, type }), + "Dynamic rule matched after update" + ); + + await dnr.updateDynamicRules({ removeRuleIds: [2] }); + browser.test.assertDeepEq( + { matchedRules: [] }, + await dnr.testMatchOutcome({ url, type }), + "Dynamic rule not matched after unregistration" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_dynamic_rules_count_limits() { + await runAsDNRExtension({ + unloadTestAtEnd: true, + awaitFinish: true, + background: async () => { + const dnr = browser.declarativeNetRequest; + const [dyamicRules, sessionRules] = await Promise.all([ + dnr.getDynamicRules(), + dnr.getSessionRules(), + ]); + + browser.test.assertDeepEq( + { session: [], dynamic: [] }, + { session: sessionRules, dynamic: dyamicRules }, + "Expect no session and no dynamic rules" + ); + + // TODO: consider exposing this as an api namespace property. + const MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES = 5000; + const DUMMY_RULE = { + action: { type: "block" }, + condition: { resourceTypes: ["main_frame"] }, + }; + const rules = []; + for (let i = 0; i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES; i++) { + rules.push({ ...DUMMY_RULE, id: i + 1 }); + } + + await browser.test.assertRejects( + dnr.updateDynamicRules({ + addRules: [ + ...rules, + { ...DUMMY_RULE, id: MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1 }, + ], + }), + /updateDynamicRules request is exceeding MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES limit \(\d+\)/, + "Got the expected rejection of exceeding the number of dynamic rules allowed" + ); + + await dnr.updateDynamicRules({ + addRules: rules, + }); + browser.test.assertEq( + 5000, + (await dnr.getDynamicRules()).length, + "Got the expected number of dynamic rules stored" + ); + + await dnr.updateDynamicRules({ + removeRuleIds: rules.map(r => r.id), + }); + + browser.test.assertEq( + 0, + (await dnr.getDynamicRules()).length, + "All dynamic rules should have been removed" + ); + + browser.test.log( + "Verify rules count limits with multiple async API calls" + ); + + const [ + updateDynamicRulesSingle, + updateDynamicRulesTooMany, + ] = await Promise.allSettled([ + dnr.updateDynamicRules({ + addRules: [ + { ...DUMMY_RULE, id: MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1 }, + ], + }), + dnr.updateDynamicRules({ addRules: rules }), + ]); + + browser.test.assertDeepEq( + updateDynamicRulesSingle, + { status: "fulfilled", value: undefined }, + "Expect the first updateDynamicRules call to be successful" + ); + + await browser.test.assertRejects( + updateDynamicRulesTooMany?.status === "rejected" + ? Promise.reject(updateDynamicRulesTooMany.reason) + : Promise.resolve(), + /updateDynamicRules request is exceeding MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES limit \(\d+\)/, + "Got the expected rejection on the second call exceeding the number of dynamic rules allowed" + ); + + browser.test.assertDeepEq( + (await dnr.getDynamicRules()).map(rule => rule.id), + [MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1], + "Got the expected dynamic rules" + ); + + await dnr.updateDynamicRules({ + removeRuleIds: [MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 1], + }); + + const [ + updateSessionResult, + updateDynamicResult, + ] = await Promise.allSettled([ + dnr.updateSessionRules({ addRules: rules }), + dnr.updateDynamicRules({ addRules: rules }), + ]); + + browser.test.assertDeepEq( + updateDynamicResult, + { status: "fulfilled", value: undefined }, + "Expect the number of dynamic rules to be still allowed, despite the session rule added" + ); + + browser.test.assertDeepEq( + updateSessionResult, + { status: "fulfilled", value: undefined }, + "Got expected success from the updateSessionRules request" + ); + + browser.test.assertDeepEq( + { sessionRulesCount: 5000, dynamicRulesCount: 5000 }, + { + sessionRulesCount: (await dnr.getSessionRules()).length, + dynamicRulesCount: (await dnr.getDynamicRules()).length, + }, + "Got expected session and dynamic rules counts" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_stored_dynamic_rules_exceeding_limits() { + const { extension } = await runAsDNRExtension({ + unloadTestAtEnd: false, + awaitFinish: false, + background: async () => { + const dnr = browser.declarativeNetRequest; + + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "createDynamicRules": { + const [{ updateRuleOptions }] = args; + await dnr.updateDynamicRules(updateRuleOptions); + break; + } + case "assertGetDynamicRulesCount": { + const [{ expectedRulesCount }] = args; + browser.test.assertEq( + expectedRulesCount, + (await dnr.getDynamicRules()).length, + "getDynamicRules() resolves to the expected number of dynamic rules" + ); + break; + } + default: + browser.test.fail( + `Got unexpected unhandled test message: "${msg}"` + ); + break; + } + browser.test.sendMessage(`${msg}:done`); + }); + browser.test.sendMessage("bgpage:ready"); + }, + }); + + const initialRules = [getDNRRule({ id: 1 })]; + await extension.awaitMessage("bgpage:ready"); + await callTestMessageHandler(extension, "createDynamicRules", { + updateRuleOptions: { addRules: initialRules }, + }); + await callTestMessageHandler(extension, "assertGetDynamicRulesCount", { + expectedRulesCount: 1, + }); + + const extUUID = extension.uuid; + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + await dnrStore._savePromises.get(extUUID); + const { storeFile } = dnrStore.getFilePaths(extUUID); + + await extension.addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`); + const dnrDataFromFile = await IOUtils.readJSON(storeFile, { + decompress: true, + }); + + const { MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES } = ExtensionDNR.limits; + + const expectedDynamicRules = []; + const unexpectedDynamicRules = []; + + for (let i = 0; i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES + 5; i++) { + const rule = getDNRRule({ id: i + 1 }); + if (i < MAX_NUMBER_OF_DYNAMIC_AND_SESSION_RULES) { + expectedDynamicRules.push(rule); + } else { + unexpectedDynamicRules.push(rule); + } + } + + const tooManyDynamicRules = [ + ...expectedDynamicRules, + ...unexpectedDynamicRules, + ]; + + const dnrDataNew = { + schemaVersion: dnrDataFromFile.schemaVersion, + extVersion: extension.extension.version, + staticRulesets: [], + dynamicRuleset: getSchemaNormalizedRules(extension, tooManyDynamicRules), + }; + + await IOUtils.writeJSON(storeFile, dnrDataNew, { compress: true }); + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + }); + + await callTestMessageHandler(extension, "assertGetDynamicRulesCount", { + expectedRulesCount: expectedDynamicRules.length, + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: new RegExp( + `Ignoring dynamic rules exceeding rule count limits while loading DNR store data for ${extension.id}` + ), + }, + ], + }); + + await extension.unload(); +}); + +add_task(async function test_save_and_load_dynamic_rules() { + let { extension, testExtensionParams } = await runAsDNRExtension({ + unloadTestAtEnd: false, + awaitFinish: false, + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "assertGetDynamicRules": { + const [{ expectedRules }] = args; + browser.test.assertDeepEq( + expectedRules, + await dnr.getDynamicRules(), + "getDynamicRules() resolves to the expected dynamic rules" + ); + break; + } + case "testUpdateDynamicRules": { + const [{ updateRulesRequests, expectedRules }] = args; + const promiseResults = await Promise.allSettled( + updateRulesRequests.map(updateRuleOptions => + dnr.updateDynamicRules(updateRuleOptions) + ) + ); + + // All calls should have been resolved successfully. + for (const [i, request] of updateRulesRequests.entries()) { + browser.test.assertDeepEq( + { status: "fulfilled", value: undefined }, + promiseResults[i], + `Expect resolved updateDynamicRules request for ${dnrTestUtils.serializeForLog( + request + )}` + ); + } + + browser.test.assertDeepEq( + expectedRules, + await dnr.getDynamicRules(), + "getDynamicRules resolves to the expected updated dynamic rules" + ); + break; + } + case "testInvalidDynamicAddRule": { + const [ + { rule, expectedError, isSchemaError, isErrorRegExp }, + ] = args; + await dnrTestUtils.testInvalidRule( + rule, + expectedError, + isSchemaError, + isErrorRegExp + ); + break; + } + default: + browser.test.fail( + `Got unexpected unhandled test message: "${msg}"` + ); + break; + } + + browser.test.sendMessage(`${msg}:done`); + }); + + browser.test.sendMessage("bgpage:ready"); + }, + }); + + await extension.awaitMessage("bgpage:ready"); + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: [], + }); + + const rules = [ + getDNRRule({ + id: 1, + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }), + getDNRRule({ + id: 2, + action: { type: "block" }, + condition: { resourceTypes: ["main_frame", "script"] }, + }), + ]; + + info("Verify updateDynamicRules adding new valid rules"); + // Send two concurrent API requests, the first one adds 3 rules and the second + // one removing a rule defined in the first call, the result of the combined + // API calls is expected to only store 2 dynamic rules in the DNR store. + await callTestMessageHandler(extension, "testUpdateDynamicRules", { + updateRulesRequests: [ + { addRules: [...rules, getDNRRule({ id: 3 })] }, + { removeRuleIds: [3] }, + ], + expectedRules: getSchemaNormalizedRules(extension, rules), + }); + + const extUUID = extension.uuid; + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + await dnrStore._savePromises.get(extUUID); + const { storeFile } = dnrStore.getFilePaths(extUUID); + const dnrDataFromFile = await IOUtils.readJSON(storeFile, { + decompress: true, + }); + + Assert.deepEqual( + dnrDataFromFile.dynamicRuleset, + getSchemaNormalizedRules(extension, rules), + "Got the expected rules stored on disk" + ); + + info("Verify updateDynamicRules rejects on new invalid rules"); + await callTestMessageHandler(extension, "testInvalidDynamicAddRule", { + rule: rules[0], + expectedError: "Duplicate rule ID: 1", + isSchemaError: false, + }); + + await callTestMessageHandler(extension, "testInvalidDynamicAddRule", { + rule: getDNRRule({ action: { type: "invalid-action" } }), + expectedError: /addRules.0.action.type: Invalid enumeration value "invalid-action"/, + isSchemaError: true, + }); + + info("Expect dynamic rules to not have been changed"); + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, rules), + }); + + Assert.deepEqual( + dnrStore._data.get(extUUID).dynamicRuleset, + getSchemaNormalizedRules(extension, rules), + "Got the expected dynamic rules in the DNR store" + ); + + info("Verify dynamic rules loaded back from disk on addon restart"); + ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`); + + // force deleting the data stored in memory to confirm if it being loaded again from + // the files stored on disk. + dnrStore._data.delete(extUUID); + dnrStore._dataPromises.delete(extUUID); + + const { addon } = extension; + await addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + await addon.enable(); + await extension.awaitMessage("bgpage:ready"); + + info("Expect dynamic rules to have been loaded back"); + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, rules), + }); + + Assert.deepEqual( + dnrStore._data.get(extUUID).dynamicRuleset, + getSchemaNormalizedRules(extension, rules), + "Got the expected dynamic rules loaded back from the DNR store after addon restart" + ); + + info("Verify dynamic rules loaded back as expected on AOM restart"); + dnrStore._data.delete(extUUID); + dnrStore._dataPromises.delete(extUUID); + await promiseRestartManager(); + await extension.awaitStartup(); + await extension.awaitMessage("bgpage:ready"); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, rules), + }); + + Assert.deepEqual( + dnrStore._data.get(extUUID).dynamicRuleset, + getSchemaNormalizedRules(extension, rules), + "Got the expected dynamic rules loaded back from the DNR store after AOM restart" + ); + + info( + "Verify updateDynamicRules adding new valid rules and remove one of the existing" + ); + // Expect the first rule to be removed and a new one being added. + const newRule3 = getDNRRule({ + id: 3, + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }); + const updatedRules = [rules[1], newRule3]; + + await callTestMessageHandler(extension, "testUpdateDynamicRules", { + updateRulesRequests: [{ addRules: [newRule3], removeRuleIds: [1] }], + expectedRules: getSchemaNormalizedRules(extension, updatedRules), + }); + + info("Verify dynamic rules preserved across addon updates"); + + const staticRules = [ + getDNRRule({ + id: 4, + action: { type: "block" }, + condition: { resourceTypes: ["xmlhttprequest"] }, + }), + ]; + await extension.upgrade({ + ...testExtensionParams, + manifest: { + ...testExtensionParams.manifest, + version: "2.0", + declarative_net_request: { + rule_resources: [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + ], + }, + }, + files: { "ruleset_1.json": JSON.stringify(staticRules) }, + }); + await extension.awaitMessage("bgpage:ready"); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, updatedRules), + }); + + info( + "Verify static rules included in the new addon version have been loaded" + ); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, staticRules), + }); + + info("Verify rules after extension downgrade"); + await extension.upgrade({ + ...testExtensionParams, + manifest: { + ...testExtensionParams.manifest, + version: "1.0", + }, + }); + await extension.awaitMessage("bgpage:ready"); + + info("Verify stored dynamic rules are unchanged"); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, updatedRules), + }); + + info( + "Verify static rules included in the new addon version are cleared on downgrade to previous version" + ); + await assertDNRStoreData(dnrStore, extension, {}); + + info("Verify rules after extension upgrade to one without DNR permissions"); + await extension.upgrade({ + ...testExtensionParams, + manifest: { + ...testExtensionParams.manifest, + permissions: [], + version: "1.1", + }, + background: async () => { + browser.test.assertEq( + browser.declarativeNetRequest, + undefined, + "Expect DNR API namespace to not be available" + ); + browser.test.sendMessage("bgpage:ready"); + }, + }); + await extension.awaitMessage("bgpage:ready"); + ok( + !dnrStore._dataPromises.has(extension.uuid), + "Expect dnrStore to not have any promise for the extension DNR data being loaded" + ); + ok( + !ExtensionDNR.getRuleManager( + extension.extension, + false /* createIfMissing */ + ), + "Expect no ruleManager found for the extenson" + ); + + info( + "Verify rules are loaded back after upgrading again to one with DNR permissions" + ); + await extension.upgrade({ + ...testExtensionParams, + manifest: { + ...testExtensionParams.manifest, + version: "1.2", + }, + }); + await extension.awaitMessage("bgpage:ready"); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: getSchemaNormalizedRules(extension, updatedRules), + }); + + let ruleManager = ExtensionDNR.getRuleManager( + extension.extension, + /* createIfMissing= */ false + ); + Assert.ok(ruleManager, "Rule manager exists before unload"); + Assert.deepEqual( + ruleManager.getDynamicRules(), + getSchemaNormalizedRules(extension, updatedRules), + "Found the expected dynamic rules in the Rule manager" + ); + await extension.addon.disable(); + Assert.ok( + !ExtensionDNR.getRuleManager( + extension.extension, + /* createIfMissing= */ false + ), + "Rule manager erased after unload" + ); + + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + + info("Verify dynamic rules updates after corrupted storage"); + + async function testLoadedRulesAfterDataCorruption({ + name, + asyncWriteStoreFile, + expectedCorruptFile, + }) { + info(`Tempering DNR store data: ${name}`); + + await extension.addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + await asyncWriteStoreFile(); + + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + + await TestUtils.waitForCondition( + () => IOUtils.exists(`${expectedCorruptFile}`), + `Wait for the "${expectedCorruptFile}" file to have been created` + ); + + ok( + !(await IOUtils.exists(storeFile)), + "Corrupted store file expected to be removed" + ); + + await callTestMessageHandler(extension, "assertGetDynamicRules", { + expectedRules: [], + }); + + const newRules = [getDNRRule({ id: 3 })]; + const expectedRules = getSchemaNormalizedRules(extension, newRules); + await callTestMessageHandler(extension, "testUpdateDynamicRules", { + updateRulesRequests: [{ addRules: newRules }], + expectedRules, + }); + + await TestUtils.waitForCondition( + () => IOUtils.exists(storeFile), + `Wait for the "${storeFile}" file to have been created` + ); + + const newData = await IOUtils.readJSON(storeFile, { decompress: true }); + Assert.deepEqual( + newData.dynamicRuleset, + expectedRules, + "Expect the new rules to have been stored on disk" + ); + } + + await testLoadedRulesAfterDataCorruption({ + name: "invalid lz4 header", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "not an lz4 compressed file", { + compress: false, + }), + expectedCorruptFile: `${storeFile}.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid json data", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "invalid json data", { compress: true }), + expectedCorruptFile: `${storeFile}-1.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "empty json data", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "{}", { compress: true }), + expectedCorruptFile: `${storeFile}-2.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid staticRulesets property type", + asyncWriteStoreFile: () => + IOUtils.writeUTF8( + storeFile, + JSON.stringify({ + schemaVersion: dnrDataFromFile.schemaVersion, + extVersion: extension.extension.version, + staticRulesets: "Not an array", + }), + { compress: true } + ), + expectedCorruptFile: `${storeFile}-3.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid dynamicRuleset property type", + asyncWriteStoreFile: () => + IOUtils.writeUTF8( + storeFile, + JSON.stringify({ + schemaVersion: dnrDataFromFile.schemaVersion, + extVersion: extension.extension.version, + staticRulesets: [], + dynamicRuleset: "Not an array", + }), + { compress: true } + ), + expectedCorruptFile: `${storeFile}-4.corrupt`, + }); + + await extension.unload(); +}); + +add_task(async function test_tabId_conditions_invalid_in_dynamic_rules() { + await runAsDNRExtension({ + unloadTestAtEnd: true, + awaitFinish: true, + background: async dnrTestUtils => { + await dnrTestUtils.testInvalidRule( + { id: 1, action: { type: "block" }, condition: { tabIds: [1] } }, + "tabIds and excludedTabIds can only be specified in session rules" + ); + await dnrTestUtils.testInvalidRule( + { + id: 1, + action: { type: "block" }, + condition: { excludedTabIds: [1] }, + }, + "tabIds and excludedTabIds can only be specified in session rules" + ); + browser.test.assertDeepEq( + [], + await browser.declarativeNetRequest.getDynamicRules(), + "Expect the invalid rules to not be enabled" + ); + browser.test.notifyPass(); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js new file mode 100644 index 0000000000..d151c83869 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_modifyHeaders.js @@ -0,0 +1,1073 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["dummy", "restricted", "yes", "no", "maybe", "cookietest"], +}); +server.registerPathHandler("/echoheaders", (req, res) => { + res.setHeader("Content-Type", "application/json"); + const headers = Object.create(null); + for (const nameSupports of req.headers) { + const name = nameSupports.QueryInterface(Ci.nsISupportsString).data; + // httpd.js automatically concats headers with ",", but in some cases it + // stores them separately, joined with "\n". + // https://searchfox.org/mozilla-central/rev/c1180ea13e73eb985a49b15c0d90e977a1aa919c/netwerk/test/httpserver/httpd.js#5271-5286 + const values = req.getHeader(name).split("\n"); + headers[name] = values.length === 1 ? values[0] : values; + } + + // Only keep custom headers, so that the test expectations does not have to + // enumerate all headers of interest. + function dropDefaultHeader(name) { + if (!(name in headers)) { + Assert.ok(false, `Header unexpectedly not found: ${name}`); + } + delete headers[name]; + } + dropDefaultHeader("host"); + dropDefaultHeader("user-agent"); + dropDefaultHeader("accept"); + dropDefaultHeader("accept-language"); + dropDefaultHeader("accept-encoding"); + dropDefaultHeader("connection"); + + res.write(JSON.stringify(headers)); +}); + +server.registerPathHandler("/host", (req, res) => { + res.write(req.getHeader("Host")); +}); + +server.registerPathHandler("/csptest", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("EXPECTED_RESPONSE_FOR /csp test"); +}); +server.registerPathHandler("/csp", (req, res) => { + // Inserting the ";" just in case something somehow merges the headers by "," + // (e.g. to "bla,; default-src http://yes http://maybe ;,bla"). + // This ensures that the server-set "default-src" CSP is not somehow mangled. + res.setHeader( + "Content-Security-Policy", + "; default-src http://yes http://maybe ;" + ); +}); + +server.registerPathHandler("/responseheadersFixture", (req, res) => { + res.setHeader("a", "server_a"); + res.setHeader("b", "server_b"); + res.setHeader("c", "server_c"); + res.setHeader("d", "server_d"); + res.setHeader("e", "server_e"); + // www-authenticate and proxy-authenticate are among the few headers where + // the test server (httpd.js) allows multiple header lines instead of + // automatically concatenating them with ",": + // https://searchfox.org/mozilla-central/rev/a4a41aafa80bf38f6e456238a60781fed46f9d08/netwerk/test/httpserver/httpd.js#5280 + res.setHeader("www-authenticate", "first_line"); + res.setHeader("www-authenticate", "second_line", /* merge */ true); + res.setHeader("proxy-authenticate", "first_line"); + res.setHeader("proxy-authenticate", "second_line", /* merge */ true); +}); + +server.registerPathHandler("/setcookie", (req, res) => { + // set-cookie is also allowed to span multiple lines. + res.setHeader("Set-Cookie", "food=yummy; max-age=999"); + res.setHeader("Set-Cookie", "second=serving; max-age=999", /* merge */ true); + res.write(req.hasHeader("Cookie") ? req.getHeader("Cookie") : ""); +}); +server.registerPathHandler("/empty", (req, res) => {}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + + // The restrictedDomains pref should be set early, because the pref is read + // only once (on first use) by WebExtensionPolicy::IsRestrictedURI. + Services.prefs.setCharPref( + "extensions.webextensions.restrictedDomains", + "restricted" + ); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + async function fetchAsJson(url, options) { + let res = await fetch(url, options); + let txt = await res.text(); + try { + return JSON.parse(txt); + } catch (e) { + return txt; + } + } + Object.assign(dnrTestUtils, { + fetchAsJson, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ + background, + manifest, + unloadTestAtEnd = true, +}) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + }); + await extension.startup(); + await extension.awaitFinish(); + if (unloadTestAtEnd) { + await extension.unload(); + } + return extension; +} + +add_task(async function modifyHeaders_requestHeaders() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { fetchAsJson } = dnrTestUtils; + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { urlFilter: "set_twice" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "a", value: "a-first" }, + // second set should be ignored after set. + { operation: "set", header: "a", value: "a-second" }, + ], + }, + }, + { + id: 2, + condition: { urlFilter: "set_and_remove" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "b", value: "b-value" }, + // remove should be ignored after set. + { operation: "remove", header: "b" }, + ], + }, + }, + { + id: 3, + condition: { urlFilter: "remove_and_set" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "remove", header: "c" }, + // set should be ignored after remove. + { operation: "set", header: "c", value: "c-value" }, + // append should be ignored after remove. + { operation: "append", header: "c", value: "c-appended" }, + ], + }, + }, + { + id: 4, + condition: { urlFilter: "remove_only" }, + action: { + type: "modifyHeaders", + requestHeaders: [{ operation: "remove", header: "d" }], + }, + }, + { + id: 5, + condition: { urlFilter: "append_twice" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "append", header: "e", value: "e-first" }, + { operation: "append", header: "e", value: "e-second" }, + ], + }, + }, + { + id: 6, + condition: { urlFilter: "set_and_append" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "f", value: "f-first" }, + { operation: "append", header: "f", value: "f-second" }, + ], + }, + }, + ], + }); + + browser.test.assertDeepEq( + { existing: "header" }, + await fetchAsJson( + "http://dummy/echoheaders?not_matching_any_dnr_rule", + { headers: { existing: "header" } } + ), + "Sanity check: should echo original headers without matching DNR rules" + ); + + // Tests set_twice rule: + + browser.test.assertDeepEq( + { a: "a-first" }, + await fetchAsJson("http://dummy/echoheaders?set_twice"), + "only the first header should be used when set twice" + ); + browser.test.assertDeepEq( + { a: "a-first" }, + await fetchAsJson("http://dummy/echoheaders?set_twice", { + headers: { a: "original" }, + }), + "original header should be overwritten by DNR" + ); + + // Tests set_and_remove rule: + + browser.test.assertDeepEq( + { b: "b-value" }, + await fetchAsJson("http://dummy/echoheaders?set_and_remove"), + "after setting a header, remove should be ignored" + ); + browser.test.assertDeepEq( + { b: "b-value" }, + await fetchAsJson("http://dummy/echoheaders?set_and_remove", { + headers: { b: "original" }, + }), + "after overwriting a header, remove should be ignored" + ); + + // Tests remove_and_set rule: + + browser.test.assertDeepEq( + { start: "START", end: "end" }, + await fetchAsJson("http://dummy/echoheaders?remove_and_set", { + headers: { start: "START", c: "remove me", end: "end" }, + }), + "after removing a header, remove should be ignored" + ); + browser.test.assertDeepEq( + {}, + await fetchAsJson("http://dummy/echoheaders?remove_and_set"), + "after a remove op (despite no existing header), set should be ignored" + ); + + // Tests remove_only rule: + + browser.test.assertDeepEq( + {}, + await fetchAsJson("http://dummy/echoheaders?remove_only", { + headers: { d: "remove me please" }, + }), + "should remove header" + ); + + // Tests append_twice rule: + + browser.test.assertDeepEq( + { e: "original, e-first, e-second" }, + await fetchAsJson("http://dummy/echoheaders?append_twice", { + headers: { e: "original" }, + }), + "should append headers" + ); + browser.test.assertDeepEq( + { e: "e-first, e-second" }, + await fetchAsJson("http://dummy/echoheaders?append_twice"), + "should append headers if there are no existing ones yet" + ); + + // Tests set_and_append rule: + + browser.test.assertDeepEq( + { f: "f-first, f-second" }, + await fetchAsJson("http://dummy/echoheaders?set_and_append", { + headers: { f: "original" }, + }), + "should overwrite and append headers" + ); + + // All rules together: + + browser.test.assertDeepEq( + { + a: "a-first", + b: "b-value", + e: "olde, e-first, e-second", + f: "f-first, f-second", + extra: "", + }, + await fetchAsJson( + "http://dummy/echoheaders?set_twice,set_and_remove,remove_and_set,remove_only,append_twice,set_and_append", + { + headers: { + a: "olda", + b: "oldb", + c: "oldc", + d: "oldd", + e: "olde", + f: "oldf", + extra: "", + }, + } + ), + "modifyHeaders actions from multiple rules should all apply" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Host header is restricted, for details see bug 1467523. +add_task(async function requestHeaders_set_host_header() { + async function background() { + const makeModifyHostRule = (id, urlFilter, value) => ({ + id, + condition: { urlFilter }, + action: { + type: "modifyHeaders", + requestHeaders: [{ operation: "set", header: "Host", value }], + }, + }); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + makeModifyHostRule(1, "yes_host_permissions", "yes"), + makeModifyHostRule(2, "no_host_permissions", "no"), + makeModifyHostRule(3, "restricted_domain", "restricted"), + ], + }); + + browser.test.assertEq( + "yes", + await (await fetch("http://dummy/host?yes_host_permissions")).text(), + "Host header value allowed if extension has permission for new value" + ); + + browser.test.assertEq( + "dummy", + await (await fetch("http://dummy/host?no_host_permissions")).text(), + "Host header value ignored if extension misses permission for new value" + ); + + browser.test.assertEq( + "dummy", + await (await fetch("http://dummy/host?restricted_domain")).text(), + "Host header value ignored if new host is in restrictedDomains" + ); + + browser.test.notifyPass(); + } + const { messages } = await promiseConsoleOutput(async () => { + await runAsDNRExtension({ + manifest: { + // Note: host_permissions without "*://no/*". + host_permissions: ["*://dummy/*", "*://yes/*", "*://restricted/*"], + }, + background, + }); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 2 from ruleset "_session"\): Error: Unable to set host header, url missing from permissions\./, + }, + { + message: /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 3 from ruleset "_session"\): Error: Unable to set host header to restricted url\./, + }, + ], + }); +}); + +add_task(async function requestHeaders_set_host_header_multiple_extensions() { + async function background() { + const hostHeaderValue = browser.runtime.getManifest().name; + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { resourceTypes: ["xmlhttprequest"] }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "Host", value: hostHeaderValue }, + // Add a unique header for each request to verify that the + // extension can still modify other headers despite failure to + // modify the host header. + { operation: "set", header: hostHeaderValue, value: "setbydnr" }, + ], + }, + }, + ], + }); + browser.test.notifyPass(); + } + // Precedence is in install order, most recent first. + // While this extension is permitted to change Host to "maybe", it has a lower + // precedence than extensionWithPermissionAndHigherPrecedence. + let extensionWithPermissionButLowerPrecedence = await runAsDNRExtension({ + unloadTestAtEnd: false, + manifest: { + name: "maybe", + host_permissions: ["*://dummy/*", "*://maybe/*"], + }, + background, + }); + // This extension is permitted to change Host to "yes". + let extensionWithPermissionAndHigherPrecedence = await runAsDNRExtension({ + unloadTestAtEnd: false, + manifest: { name: "yes", host_permissions: ["*://dummy/*", "*://yes/*"] }, + background, + }); + // While this extension has the highest precedence by install order, it does + // not have permission to change "Host" to "no". + let extensionWithoutPermissionForHostHeader = await runAsDNRExtension({ + unloadTestAtEnd: false, + manifest: { name: "no", host_permissions: ["*://dummy/*"] }, + background, + }); + + Assert.equal( + await ExtensionTestUtils.fetch("http://dummy/", "http://dummy/host"), + "yes", + "Host header changedby the most recently installed extension with the right permission" + ); + + const { messages, result } = await promiseConsoleOutput(() => + ExtensionTestUtils.fetch("http://dummy/", "http://dummy/echoheaders") + ); + Assert.equal( + result, + `{"referer":"http://dummy/","no":"setbydnr","yes":"setbydnr","maybe":"setbydnr"}`, + "Host header changedby the most recently installed extension with the right permission" + ); + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: /Failed to apply modifyHeaders action to header "Host" \(DNR rule id 1 from ruleset "_session"\): Error: Unable to set host header, url missing from permissions\./, + }, + ], + }); + + await extensionWithPermissionButLowerPrecedence.unload(); + await extensionWithPermissionAndHigherPrecedence.unload(); + await extensionWithoutPermissionForHostHeader.unload(); +}); + +add_task(async function modifyHeaders_responseHeaders() { + await runAsDNRExtension({ + background: async () => { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { urlFilter: "/responseheadersFixture" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { operation: "set", header: "a", value: "a-first" }, + // remove after set should be ignored: + { operation: "remove", header: "a" }, + // Second set should be ignored: + { operation: "set", header: "a", value: "a-second" }, + // But append is permitted: + { operation: "append", header: "a", value: "a-third" }, + // Another append is allowed too: + { operation: "append", header: "a", value: "a-fourth" }, + // An unrelated set is accepted: + { operation: "set", header: "b", value: "b-dnr" }, + // An unrelated remove is also accepted: + { operation: "remove", header: "c" }, + // An unrelated append is also accepted: + { operation: "append", header: "d", value: "d-dnr" }, + // The server also sends the "e" header, we don't touch that. + + // The server sends the www-authenticate header on two lines, + // which should be removed. + { operation: "remove", header: "www-authenticate" }, + // The server also sends the proxy-authenticate header on two + // lines, but we don't touch that. + ], + }, + }, + ], + }); + + let { headers } = await fetch("http://dummy/responseheadersFixture"); + browser.test.assertEq( + "a-first, a-third, a-fourth", + headers.get("a"), + "a set, ignored set + remove, 2x append" + ); + browser.test.assertEq("b-dnr", headers.get("b"), "b set"); + browser.test.assertEq(null, headers.get("c"), "c removed"); + browser.test.assertEq("server_d, d-dnr", headers.get("d"), "d appended"); + browser.test.assertEq("server_e", headers.get("e"), "e not touched"); + browser.test.assertEq( + null, + headers.get("www-authenticate"), + "multi-line www-authenticate header removed" + ); + + // Multi-line http headers cannot be tested through fetch/Headers. This is + // a known limitation of that API, see e.g. note about Set-Cookie in the + // fetch spec - https://fetch.spec.whatwg.org/#headers-class + browser.test.assertEq( + null, // Note: null because Headers does not see multi-line headers. + headers.get("proxy-authenticate"), + "multi-line proxy-authenticate header kept (but fetch cannot see it)" + ); + + // XMLHttpRequest can return multi-line values, so we use that instead. + const xhr = new XMLHttpRequest(); + await new Promise(r => { + xhr.onloadend = r; + xhr.open("GET", "http://dummy/responseheadersFixture?xhr"); + xhr.send(); + }); + browser.test.assertEq( + null, + xhr.getResponseHeader("www-authenticate"), + "multi-line www-authenticate header removed" + ); + browser.test.assertEq( + "first_line\nsecond_line", + xhr.getResponseHeader("proxy-authenticate"), + "multi-line proxy-authenticate header kept (seen through XHR)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function responseHeaders_set_content_security_policy_header() { + let extension = await runAsDNRExtension({ + unloadTestAtEnd: false, + background: async () => { + // By default, a DNR condition excludes the main frame. But to verify that + // the CSP works, we have to modify the CSP header of a document request. + const resourceTypes = ["main_frame"]; + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { resourceTypes, urlFilter: "/csp?remove" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { operation: "remove", header: "Content-Security-Policy" }, + ], + }, + }, + { + id: 2, + condition: { resourceTypes, urlFilter: "/csp?append_to_server" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "append", + header: "Content-Security-Policy", + // Server has "default-src http://yes http://maybe". When + // multiple CSP header lines are present, all policies should + // be enforced, thus "http://no" below should be ignored, and + // the "http://maybe" from the server be ignored. + value: "connect-src http://YES http://not-maybe http://no", + }, + ], + }, + }, + { + id: 3, + condition: { resourceTypes, urlFilter: "/csp?set_and_append" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "set", + header: "Content-Security-Policy", + value: "connect-src 1-of-2 http://yes http://maybe", + }, + { + operation: "append", + header: "Content-Security-Policy", + value: "connect-src 2-of-2 http://yes", + }, + ], + }, + }, + ], + }); + + browser.test.notifyPass(); + }, + }); + + async function testFetchAndCSP(url) { + info(`testFetchAndCSP: ${url}`); + let contentPage = await ExtensionTestUtils.loadContentPage(url); + let cspTestResults = await contentPage.spawn(null, async () => { + const { document } = content; + async function doFetchAndCheckCSP(url) { + const cspTestResult = { url, violatedCSP: [] }; + let cspListener; + let cspEventPromise = new Promise(resolve => { + cspListener = e => { + cspTestResult.violatedCSP.push(e.originalPolicy); + // A CSP violation results in an event for each violated policy, + // dispatched after each other. Post a macrotask to ensure that all + // violations are caught. + content.setTimeout(resolve, 0); + }; + }); + document.addEventListener("securitypolicyviolation", cspListener); + try { + let res = await content.fetch(url); + let responseText = await res.text(); + if (responseText !== "EXPECTED_RESPONSE_FOR /csp test") { + cspTestResult.unexpectedResponseText = responseText; + } + // No await cspEventPromise, because we are not expecting any errors. + // If there was any CSP violation, we would have ended in catch. + } catch (e) { + dump(`\nFailed to fetch ${url}, waiting for CSP report/event.\n`); + await cspEventPromise; + } + document.removeEventListener("securitypolicyviolation", cspListener); + return cspTestResult; + } + + return { + yes: await doFetchAndCheckCSP("http://yes/csptest"), + maybe: await doFetchAndCheckCSP("http://maybe/csptest"), + no: await doFetchAndCheckCSP("http://no/csptest"), + }; + }); + await contentPage.close(); + return cspTestResults; + } + + // Note: this is derived from the server's policy. The server sends a bit more + // in the Content-Security-Policy header (i.e. ";"), but the normalized form + // is as follows. + const SERVER_DEFAULT_CSP = "default-src http://yes http://maybe"; + + // First, sanity check: + Assert.deepEqual( + await testFetchAndCSP("http://dummy/csp"), + { + yes: { url: "http://yes/csptest", violatedCSP: [] }, + maybe: { url: "http://maybe/csptest", violatedCSP: [] }, + no: { url: "http://no/csptest", violatedCSP: [SERVER_DEFAULT_CSP] }, + }, + "Sanity check: Server sends CSP that only allows requests to http://yes." + ); + + Assert.deepEqual( + await testFetchAndCSP("http://dummy/csp?remove"), + { + yes: { url: "http://yes/csptest", violatedCSP: [] }, + maybe: { url: "http://maybe/csptest", violatedCSP: [] }, + no: { url: "http://no/csptest", violatedCSP: [] }, + }, + "DNR remove CSP: results in no requests blocked by CSP" + ); + + Assert.deepEqual( + { + yes: { url: "http://yes/csptest", violatedCSP: [] }, + maybe: { + url: "http://maybe/csptest", + violatedCSP: [ + // This value was appended by DNR (with upper-case "http://YES", but + // the normalized form should be lowercase "http://yes"), and notably + // the "yes" request above should still pass. + "connect-src http://yes http://not-maybe http://no", + ], + }, + no: { url: "http://no/csptest", violatedCSP: [SERVER_DEFAULT_CSP] }, + }, + await testFetchAndCSP("http://dummy/csp?append_to_server"), + "DNR append CSP: should enforce CSP of server and DNR" + ); + + Assert.deepEqual( + await testFetchAndCSP("http://dummy/csp?set_and_append"), + { + yes: { url: "http://yes/csptest", violatedCSP: [] }, + maybe: { + url: "http://maybe/csptest", + violatedCSP: [ + // Note: "http://" is before 2-of-2 due to bug 1804145. + "connect-src http://2-of-2 http://yes", + ], + }, + no: { + url: "http://no/csptest", + violatedCSP: [ + // Note: "http://" is before 1-of-2 and 2-of-2 due to bug 1804145. + "connect-src http://1-of-2 http://yes http://maybe", + "connect-src http://2-of-2 http://yes", + ], + }, + }, + "DNR set + append CSP: should enforce both CSPs from DNR" + ); + + await extension.unload(); +}); + +// Set-Cookie is special because it may span multiple lines. This test tests a +// combination of requestHeaders/responseHeaders and that the DNR-set cookies +// are really working, i.e. visible to server and/or modifying the client's +// cookie jar. +add_task(async function requestHeaders_and_responseHeaders_cookies() { + let extension = await runAsDNRExtension({ + unloadTestAtEnd: false, + background: async () => { + // By default, a DNR condition excludes the main frame. But this test uses + // a document load to verify that cookie header modifications (if any) are + // reflected in document.cookie. + const resourceTypes = ["main_frame"]; + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { resourceTypes, urlFilter: "dnr_resp_drop_cookie" }, + action: { + type: "modifyHeaders", + responseHeaders: [{ operation: "remove", header: "set-cookie" }], + }, + }, + { + id: 2, + condition: { resourceTypes, urlFilter: "dnr_resp_set_cookie" }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "set", + header: "set-cookie", + value: "dnr_res=set; max-age=999", + }, + ], + }, + }, + { + id: 3, + condition: { resourceTypes, urlFilter: "dnr_set_cookie_to_req" }, + action: { + type: "modifyHeaders", + requestHeaders: [ + { operation: "set", header: "cookie", value: "dnr_req=1" }, + ], + }, + }, + { + id: 4, + condition: { + resourceTypes, + urlFilter: "dnr_append_cookie_to_req_and_res", + }, + action: { + type: "modifyHeaders", + requestHeaders: [ + // Just for extra coverage, mix upper/lower case. + { operation: "append", header: "Cookie", value: "DNR_APP=1" }, + { operation: "append", header: "cookie", value: "DNR_app=2" }, + ], + responseHeaders: [ + { + operation: "append", + header: "set-cookie", + value: "dnr_res=appended; max-age=999", + }, + ], + }, + }, + { + id: 5, + condition: { + resourceTypes, + urlFilter: "dnr_set_server_cookies_expired", + }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "set", + header: "set-cookie", + value: "food=deletedbydnr; second=deletedbydnr; max-age=-1", + }, + { + operation: "append", + header: "set-cookie", + value: "second=deletedbydnr; max-age=-1", + }, + ], + }, + }, + { + id: 6, + condition: { + resourceTypes, + urlFilter: "dnr_resp_append_expired_cookie", + }, + action: { + type: "modifyHeaders", + responseHeaders: [ + { + operation: "append", + header: "set-cookie", + value: "dnr_res=deleteme; max-age=-1", + }, + ], + }, + }, + ], + }); + + browser.test.notifyPass(); + }, + }); + + async function loadPageAndGetCookies(pathAndQuery) { + const url = `http://cookietest${pathAndQuery}`; + info(`loadPageAndGetCookies: ${url}`); + let contentPage = await ExtensionTestUtils.loadContentPage(url); + let res = await contentPage.spawn(null, () => { + const { document } = content; + const sortCookies = s => + s + .split("; ") + .sort() + .join("; "); + return { + // Server at /setcookie echos value of Cookie request header. + serverSeenCookies: sortCookies(document.body.textContent), + clientSeenCookies: sortCookies(document.cookie), + }; + }); + await contentPage.close(); + return res; + } + + Assert.deepEqual( + { serverSeenCookies: "", clientSeenCookies: "" }, + await loadPageAndGetCookies("/setcookie?dnr_resp_drop_cookie"), + "Set-Cookie from server ignored due to DNR (remove Set-Cookie)" + ); + Assert.deepEqual( + { + serverSeenCookies: "", + clientSeenCookies: "dnr_res=set", + }, + await loadPageAndGetCookies("/setcookie?dnr_resp_set_cookie"), + "Set-Cookie from server overwritten by DNR (set Set-Cookie)" + ); + Assert.deepEqual( + { + // No cookies from previous request + request-specific cookie from DNR. + serverSeenCookies: "dnr_req=1", + // Notably, "dnr_req=1" should be missing from clientSeenCookies, because + // it is added in the request, so only seen by the server. Only cookies + // set by Set-Cookie are persisted/seen by the client. + clientSeenCookies: "dnr_res=set; food=yummy; second=serving", + }, + await loadPageAndGetCookies("/setcookie?dnr_set_cookie_to_req"), + "Cookie req header from DNR, shadows existing client-generated Cookie header" + ); + Assert.deepEqual( + { + // Cookies from previous request + request-specific cookies from DNR. + serverSeenCookies: + "DNR_APP=1; DNR_app=2; dnr_res=set; food=yummy; second=serving", + // NDR_APP and DNR_app are notably missing. dnr_res was modified by DNR, + // because an appended cookie with the same name overwrites existing one. + clientSeenCookies: "dnr_res=appended; food=yummy; second=serving", + }, + await loadPageAndGetCookies("/setcookie?dnr_append_cookie_to_req_and_res"), + "Cookie req header from DNR, merged with existing client cookies; Set-Cookie from server merged with DNR (append Set-Cookie)" + ); + Assert.deepEqual( + { + // Cookies from previous request (not changed by DNR): + serverSeenCookies: "dnr_res=appended; food=yummy; second=serving", + // Server cookies removed, only previously added DNR cookie sticks: + clientSeenCookies: "dnr_res=appended", + }, + await loadPageAndGetCookies("/setcookie?dnr_set_server_cookies_expired"), + "Set-Cookie from server expired by DNR (set Set-Cookie + expire server cookies)" + ); + Assert.deepEqual( + { + // Cookies from previous request (not changed by DNR): + serverSeenCookies: "dnr_res=appended", + // Cookies from server; because we used "append", they should merge, and + // expire the previous DNR cookie, and create the server-set cookies. + clientSeenCookies: "food=yummy; second=serving", + }, + await loadPageAndGetCookies("/setcookie?dnr_resp_append_expired_cookie"), + "Set-Cookie from server merged with DNR (append Set-Cookie + expire dnr_res)" + ); + // We've already tested dnr_set_server_cookies_expired before, now we're just + // cleaning up. + Assert.deepEqual( + { + serverSeenCookies: "food=yummy; second=serving", + clientSeenCookies: "", + }, + await loadPageAndGetCookies("/setcookie?dnr_set_server_cookies_expired"), + "DNR cleared remaining cookies (set Set-Cookie + expire server cookies)" + ); + + await extension.unload(); +}); + +// This test confirms the effective modifyHeaders actions if multiple extensions +// have matching modifyHeaders rules. Only one extension is allowed to modify +// headers. +add_task(async function modifyHeaders_multiple_extensions() { + async function background() { + const extName = browser.runtime.getManifest().name; + function makeModifyHeadersRule(id, operation, headerName) { + const urlFilter = `${extName}_${operation}_${headerName}`; + let value; + if (operation !== "remove") { + // Use the urlFilter as value so that it's obvious which rule added it. + value = urlFilter; + } + return { + id, + condition: { urlFilter }, + action: { + type: "modifyHeaders", + // As the logic of responseHeaders and requestHeaders is shared, it + // suffices to only check responseHeaders here. + responseHeaders: [{ operation, header: headerName, value }], + }, + }; + } + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + makeModifyHeadersRule(1, "set", "a"), + makeModifyHeadersRule(2, "remove", "a"), + makeModifyHeadersRule(3, "append", "a"), + makeModifyHeadersRule(4, "set", "b"), + makeModifyHeadersRule(5, "remove", "b"), + makeModifyHeadersRule(6, "append", "b"), + ], + }); + browser.test.notifyPass(); + } + + // Cross-extension rule precedence is in the order of extension installation. + const prioTwoExtension = await runAsDNRExtension({ + manifest: { name: "prioTwo" }, + background, + unloadTestAtEnd: false, + }); + const prioOneExtension = await runAsDNRExtension({ + manifest: { name: "prioOne" }, + background, + unloadTestAtEnd: false, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://dummy/empty" + ); + async function checkHeaderActionResult(query, expectedHeaders, description) { + const url = `/responseheadersFixture?${query}`; + const result = await contentPage.spawn(url, async url => { + const res = await content.fetch(url); + return { + a: res.headers.get("a"), + b: res.headers.get("b"), + }; + }); + Assert.deepEqual( + result, + expectedHeaders, + `${description} - Expected headers for ${url}` + ); + } + + await checkHeaderActionResult( + "", + { + a: "server_a", + b: "server_b", + }, + "Sanity check: headers should be unmodified without matching DNR rules" + ); + + // First: verify that "set" is only permitted if there are no other extensions + // that have already modified the header. Note that this requirement already + // holds for actions within one extension, so they should still be enforced + // for modifyHeaders actions from multiple extensions. + await checkHeaderActionResult( + "prioOne_set_a,prioTwo_set_a,prioTwo_set_b", + { + a: "prioOne_set_a", + b: "prioTwo_set_b", + }, + "set should only be allowed if no other extension has set a header" + ); + await checkHeaderActionResult( + "prioOne_remove_a,prioTwo_set_a,prioTwo_set_b", + { + a: null, + b: "prioTwo_set_b", + }, + "set should only be allowed if no other extension has removed the header" + ); + await checkHeaderActionResult( + "prioOne_append_a,prioTwo_set_a,prioTwo_set_b", + { + a: "server_a, prioOne_append_a", + b: "prioTwo_set_b", + }, + "set should only be allowed if no other extension has appended the header" + ); + + // The "remove" operation is not logically conflicting, let's confirm that it + // works as usual. + await checkHeaderActionResult( + "prioOne_remove_a,prioTwo_remove_a,prioTwo_remove_b", + { + a: null, + b: null, + }, + "remove should work, regardless of the number of extensions that use it" + ); + + // While an extension can specify multiple "append" operations, only one + // extension should be able to use it. Another extension is still allowed to + // modify an unrelated, not-yet-modified header. + await checkHeaderActionResult( + "prioOne_append_a,prioTwo_append_a,prioTwo_append_b", + { + a: "server_a, prioOne_append_a", + b: "server_b, prioTwo_append_b", + }, + "Only one extension may modify a specific header" + ); + + await contentPage.close(); + await prioOneExtension.unload(); + await prioTwoExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js new file mode 100644 index 0000000000..d94c31c858 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_private_browsing.js @@ -0,0 +1,130 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); +}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +async function startDNRExtension({ privateBrowsingAllowed }) { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: privateBrowsingAllowed ? "spanning" : undefined, + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + browser.test.sendMessage("dnr_registered"); + }, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + browser_specific_settings: { gecko: { id: "@dnr-ext" } }, + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + return extension; +} + +async function testMatchedByDNR(privateBrowsing) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/?page", + { privateBrowsing } + ); + let wasRequestBlocked = await contentPage.spawn(null, async () => { + try { + await content.fetch("http://example.com/?fetch"); + return false; + } catch (e) { + // Request blocked by DNR rule from startDNRExtension(). + return true; + } + }); + await contentPage.close(); + return wasRequestBlocked; +} + +add_task(async function private_browsing_not_allowed_by_default() { + let extension = await startDNRExtension({ privateBrowsingAllowed: false }); + Assert.equal( + await testMatchedByDNR(false), + true, + "DNR applies to non-private browsing requests by default" + ); + Assert.equal( + await testMatchedByDNR(true), + false, + "DNR not applied to private browsing requests by default" + ); + await extension.unload(); +}); + +add_task(async function private_browsing_allowed() { + let extension = await startDNRExtension({ privateBrowsingAllowed: true }); + Assert.equal( + await testMatchedByDNR(false), + true, + "DNR applies to non-private requests regardless of privateBrowsingAllowed" + ); + Assert.equal( + await testMatchedByDNR(true), + true, + "DNR applied to private browsing requests when privateBrowsingAllowed" + ); + await extension.unload(); +}); + +add_task( + { pref_set: [["extensions.dnr.feedback", true]] }, + async function testMatchOutcome_unaffected_by_privateBrowsing() { + let extensionWithoutPrivateBrowsingAllowed = await startDNRExtension({}); + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + files: { + "page.html": `<!DOCTYPE html><script src="page.js"></script>`, + "page.js": async () => { + browser.test.assertTrue( + browser.extension.inIncognitoContext, + "Extension page is opened in a private browsing context" + ); + browser.test.assertDeepEq( + { + matchedRules: [ + { ruleId: 1, rulesetId: "_session", extensionId: "@dnr-ext" }, + ], + }, + // testMatchOutcome does not offer a way to specify the private + // browsing mode of a request. Confirm that testMatchOutcome always + // simulates requests in normal private browsing mode, even if the + // testMatchOutcome method itself is called from an extension page + // in private browsing mode. + await browser.declarativeNetRequest.testMatchOutcome( + { url: "http://example.com/?simulated_request", type: "image" }, + { includeOtherExtensions: true } + ), + "testMatchOutcome includes DNR from extensions without pbm access" + ); + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html`, + { privateBrowsing: true } + ); + await extension.awaitMessage("done"); + await contentPage.close(); + await extension.unload(); + await extensionWithoutPrivateBrowsingAllowed.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js new file mode 100644 index 0000000000..cbbfc8bc94 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_redirect_transform.js @@ -0,0 +1,725 @@ +"use strict"; + +// The validate_action_redirect_transform task of test_ext_dnr_session_rules.js +// confirms that redirect transform rules meet some minimum bar of validation. +// Despite passing validation, there are still interesting cases to explore, +// ranging from verifying that special characters appear as expected, to +// verifying that an invalid URL (e.g. too long after the transform) is handled +// reasonably well. + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + + // Allow navigation to URLs with embedded credentials, without prompt. + Services.prefs.setBoolPref("network.auth.confirmAuth.enabled", false); +}); + +const server = createHttpServer({ + hosts: ["from", "dest", "127.0.0.127", "[::1]", "xn--stra-yna.de", "fqdn."], +}); +server.identity.add("http", "dest", 443); // test_redirect_transform_port +server.identity.add("http", "dest", 700); // test_redirect_transform_port +server.identity.add("http", "dest", 777); // Dummy port in test cases. + +server.registerPrefixHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("GOOD_RESPONSE"); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + function makeRedirectTransformRule(transform) { + return { + id: 1, + condition: { requestDomains: ["from"] }, + action: { + type: "redirect", + // redirect to "dest" by default, different from "from", to avoid an + // infinite redirect loop. + redirect: { transform: { host: "dest", ...transform } }, + }, + }; + } + async function setRedirectTransform(transform) { + await dnr.updateSessionRules({ + removeRuleIds: [1], + addRules: [makeRedirectTransformRule(transform)], + }); + } + // testFetch is simple/fast, but cannot always be used: + // - when the request URL contains embedded credentials. + // - when the final URL is supposed to contain a reference fragment. + async function testFetch(from, to, description) { + let res = await fetch(from); + browser.test.assertEq(to, res.url, description); + browser.test.assertEq("GOOD_RESPONSE", await res.text(), "expected body"); + } + // testNavigate is the slower, complex version of testFetch. It should be + // used in tests where the username, password or fragment components of a URL + // are significant. + async function testNavigate(from, to, description) { + let resultPromise = new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg, result) { + if (msg === "test_navigate_result") { + browser.test.onMessage.removeListener(listener); + // resolve only resolves on the first call, which is ideal because + // browser.test.onMessage.removeListener does not work (bug 1428213). + resolve(result); + } + }); + }); + browser.test.sendMessage("test_navigate", from); + browser.test.assertDeepEq({ from, to }, await resultPromise, description); + } + Object.assign(dnrTestUtils, { + makeRedirectTransformRule, + setRedirectTransform, + testFetch, + testNavigate, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ background, manifest }) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + web_accessible_resources: [ + { resources: ["war.txt"], matches: ["http://from/*"] }, + ], + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + files: { + "war.txt": "GOOD_RESPONSE", + "nowar.txt": "nowar.txt is not in web_accessible_resources", + }, + }); + extension.onMessage("test_navigate", async url => { + // The DNR rule does not redirect the main frame. + let contentPage = await ExtensionTestUtils.loadContentPage("http://from/"); + info(`Loading ${url}`); + await contentPage.spawn(url, async url => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = url; + await new Promise(resolve => { + frame.onload = resolve; + document.body.appendChild(frame); + }); + }); + let finalURL = contentPage.browsingContext.children[0].currentURI.spec; + await contentPage.close(); + extension.sendMessage("test_navigate_result", { from: url, to: finalURL }); + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +} + +add_task(async function test_redirect_transform_all_at_once() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ + scheme: "http", + username: "a", + password: "b", + host: "dest", + port: "777", + path: "/d", + query: "?e", + queryTransform: null, + fragment: "#f", + }); + await testFetch( + "https://from", + "http://a:b@dest:777/d?e", // note: fetch cannot see '#f'. + "Adds components to minimal URL (fetch)" + ); + await testNavigate( + "https://from", + "http://a:b@dest:777/d?e#f", + "Adds components to minimal URL (navigation)" + ); + + await browser.test.assertRejects( + testFetch("https://user:pass@from:777/path?query#ref"), + "Window.fetch: https://user:pass@from:777/path?query#ref is an url with embedded credentials.", + "fetch does not work with embedded credentials" + ); + await testNavigate( + "https://user:pass@from:777/path?query#ref", + "http://a:b@dest:777/d?e#f", + "Replaces all components in existing URL (navigation)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_scheme() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ scheme: "http" }); + await testFetch("https://from/", "http://dest/", "scheme change"); + await testNavigate( + "https://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query#ref", + "scheme change in complex URL with embedded credentials" + ); + + await setRedirectTransform({ + scheme: "moz-extension", + host: location.hostname, + }); + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1745761#c7 + // When extensions.webextensions.remote is false (e.g. on Android), + // a redirect to a moz-extension:-URL reveals the underlying jar/file + // URL, instead of the moz-extension:-URL. + // TODO bug 1802385: fix bug and also run the following part on Android. + if (!navigator.userAgent.includes("Android")) { + await testFetch( + "http://from/war.txt", + browser.runtime.getURL("war.txt"), + "Scheme change to moz-extension:-URL" + ); + } + // While the initiator (extension) would be allowed to read the resource + // due to it being same-origin, the pre-redirect URL (http://from) is not + // matching web_accessible_resources[].matches, so the load is rejected. + // This scenario is also tested in test_ext_dnr_without_webrequest.js, at + // the redirect_request_with_dnr_to_extensionPath task. + await browser.test.assertRejects( + testFetch("http://from/nowar.txt"), + "NetworkError when attempting to fetch resource.", + "Cannot load redirect to moz-extension: not in web_accessible_resources" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_username() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ username: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://:pass@dest:777/path?query#ref", + "username cleared" + ); + + await setRedirectTransform({ username: "new" }); + // Cannot pass credentials to fetch, but can read from response.url: + await testFetch("http://from/", "http://new@dest/", "username added"); + await testNavigate("http://from/", "http://new@dest/", "username added"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://new:pass@dest:777/path?query#ref", + "username changed" + ); + + await setRedirectTransform({ username: "new User:name@%%20/" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://new%20User%3Aname%40%%20%2F:pass@dest:777/path?query#ref", + "username changed to complex value" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_password() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ password: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user@dest:777/path?query#ref", + "password cleared" + ); + + await setRedirectTransform({ password: "new" }); + // Cannot pass credentials to fetch, but can read from response.url: + await testFetch("http://from/", "http://:new@dest/", "password added"); + await testNavigate("http://from/", "http://:new@dest/", "password added"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:new@dest:777/path?query#ref", + "password changed" + ); + + await setRedirectTransform({ password: "new Pass:@%%20/" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:new%20Pass%3A%40%%20%2F@dest:777/path?query#ref", + "password changed to complex value" + ); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_host() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ host: "dest" }); + await testFetch( + "http://from:777/path?query", + "http://dest:777/path?query", + "host changed" + ); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query#ref", + "host changed without affecting embedded credentials" + ); + + await setRedirectTransform({ host: "DEST" }); + await testFetch( + "http://from/", + "http://dest/", + "host changed (non-canonical, upper case)" + ); + + await setRedirectTransform({ host: "%44%65%73%54" }); // "DesT", escaped. + await testFetch( + "http://from:777/", + "http://dest:777/", + "host changed (non-canonical, percent-escaped)" + ); + + await setRedirectTransform({ host: "127.0.0.127" }); + await testFetch( + "http://from/", + "http://127.0.0.127/", + "host change to IPv4" + ); + + await setRedirectTransform({ host: "[::1]" }); + await testFetch("http://from/", "http://[::1]/", "host change to IPv6"); + + await setRedirectTransform({ host: "xn--stra-yna.de" }); + await testFetch( + "http://from/", + "http://xn--stra-yna.de/", + "host change to IDN (internationalized domain name, in punycode)" + ); + + await setRedirectTransform({ host: "straß.de" }); + await testFetch( + "http://from/", + "http://xn--stra-yna.de/", + "host change to IDN (not punycode-encoded)" + ); + + await setRedirectTransform({ host: "fqdn." }); + await testFetch( + "http://from/", + "http://fqdn./", + "host change to FQDN (fully-qualified domain name)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_port() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ port: "" }); + await testFetch("http://from:777/", "http://dest/", "port cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest/path?query#ref", + "port cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ port: "700" }); + await testFetch("http://from/", "http://dest:700/", "port added"); + await testFetch("http://from:777/", "http://dest:700/", "port changed"); + + // 0-padded should not be misinterpreted as an octal number. + await setRedirectTransform({ port: "0700" }); + await testFetch( + "http://from:777/", + "http://dest:700/", + "port changed (non-canonical, 0-padded port)" + ); + + await setRedirectTransform({ port: "80" }); + await testFetch( + "http://from:777/", + "http://dest/", + "port cleared if default protocol" + ); + + await setRedirectTransform({ scheme: "http", port: "443" }); + await testFetch( + "https://from/", + "http://dest:443/", + "port added if new port is not default port of new protocol" + ); + + await setRedirectTransform({ scheme: "http", port: "80" }); + await testFetch( + "https://from:777/", + "http://dest/", + "port cleared if new port is default port of new protocol" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_path() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ path: "" }); + await testFetch("http://from/path", "http://dest/", "path cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/?query#ref", + "path cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ path: "/new" }); + await testFetch("http://from/", "http://dest/new", "path added"); + await testFetch("http://from/path", "http://dest/new", "path changed"); + + await setRedirectTransform({ path: "///" }); + await testFetch("http://from/", "http://dest///", "path added (///)"); + + await setRedirectTransform({ path: "path" }); + await testFetch( + "http://from/", + "http://dest/path", + "path added (non-canonical, missing slash)" + ); + + // " " -> "%20" (space) + // "\x00" -> "%00" (null byte) + // "<>" -> "%3C%3E" (URL encoding of angle brackets) + // "%", "%20", "%3A", "%3a" -> not changed (%-encoding kept as-is). + await setRedirectTransform({ path: "/Path_%_ _%20_?_#_\x00_<>_%3A%3a" }); + await testFetch( + "http://from/", + "http://dest/Path_%_%20_%20_%3F_%23_%00_%3C%3E_%3A%3a", + "path added (non-canonical, partial percent encoding)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_query() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform, testFetch, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ query: "" }); + await testFetch("http://from/?query", "http://dest/", "query cleared"); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path#ref", + "query cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ query: "?new" }); + await testFetch("http://from/", "http://dest/?new", "query added"); + await testFetch( + "http://from/?query", + "http://dest/?new", + "query changed" + ); + + await setRedirectTransform({ query: "?" }); + await testFetch("http://from/", "http://dest/?", "query set to just '?'"); + + await setRedirectTransform({ query: "?Query_#_ _%20_%3a%3A_<>_\x00" }); + await testFetch( + "http://from/", + "http://dest/?Query_%23_%20_%20_%3a%3A_%3C%3E_%00", + "query added (non-canonical, partial percent encoding)" + ); + + // Now rule.action.redirect.transform.queryTransform: + await setRedirectTransform({ + queryTransform: { + removeParams: ["query"], + }, + }); + await testFetch( + "http://from/?query", + "http://dest/", + "queryTransform removed query" + ); + await testFetch( + "http://from/?prefix&query&suffix", + "http://dest/?prefix&suffix", + "queryTransform removed part of query" + ); + await testFetch( + "http://from/?query&aquery&queryb&query=withvalue¬=query&QUERY&", + "http://dest/?aquery&queryb¬=query&QUERY&", + "queryTransform removed all occurrences of 'query' key" + ); + await testFetch( + "http://from/??query", + "http://dest/??query", + "queryTransform does not match param when it starts with '??'" + ); + + await setRedirectTransform({ + queryTransform: { + removeParams: ["query"], + addOrReplaceParams: [{ key: "query", value: "newvalue" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=newvalue", + "queryTransform appended query despite new param being in removeParams" + ); + await testFetch( + "http://from/?prefix&query&suffix", + "http://dest/?prefix&suffix&query=newvalue", + "queryTransform removed query, and appended new value" + ); + await testFetch( + "http://from/??query", + "http://dest/??query&query=newvalue", + "queryTransform ignores existing param starting with '??', and appends" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "query", value: "newvalue" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=newvalue", + "queryTransform appended query" + ); + await testFetch( + "http://from/?prefix&query=oldvalue&query=2&query=3", + "http://dest/?prefix&query=newvalue&query=2&query=3", + "queryTransform replaced the first occurrence and kept the others" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [ + { key: "r", value: "default" }, // default:false + { key: "r", value: "false", replaceOnly: false }, + { key: "r", value: "true", replaceOnly: true }, + { key: "r", value: "false2", replaceOnly: false }, + { key: "r", value: "true2", replaceOnly: true }, + ], + }, + }); + // r=true and r=true2 are missing because there are no matching "r". + await testFetch( + "http://from/", + "http://dest/?r=default&r=false&r=false2", + "queryTransform appends all except replaceOnly=true" + ); + // r=true2 should be missing because there is no matching "r". + await testFetch( + "http://from/?r=1&r=2&r=3&___", + "http://dest/?r=default&r=false&r=true&___&r=false2", + "queryTransform replaced in order and ignores last replaceOnly=true" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [ + { key: "a", value: "appenda" }, + { key: "b", value: "b1" }, + { key: "c", value: "c1" }, + { key: "c", value: "c2" }, + { key: "c", value: "appendc" }, + { key: "d", value: "d1" }, + ], + }, + }); + // Test case has: b c c d. + // Rule only has: appenda b1 c2 appendc d1. + // Expected out : b1 c2 d1 appenda appendc. + await testFetch( + "http://from/?b=01&c=02&c=03&d=06", + "http://dest/?b=b1&c=c1&c=c2&d=d1&a=appenda&c=appendc", + "queryTransform replaces matched queries and appends the rest, in order" + ); + + await setRedirectTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "query", value: " _+_%00_#" }], + }, + }); + await testFetch( + "http://from/", + "http://dest/?query=+_%2B_%2500_%23", + "queryTransform urlencodes values" + ); + + // This part tests how param names with non-alphanumeric characters can be + // (and not be) matched and replaced. This follows Chrome's behavior, see + // https://bugzilla.mozilla.org/show_bug.cgi?id=1801870#c1 + await setRedirectTransform({ + queryTransform: { + removeParams: ["?x", "%3Fx", "&x", "%26x"], + addOrReplaceParams: [ + // Internally interpreted as: %3Fp: + { key: "?p", value: "rawq", replaceOnly: true }, + // Internally interpreted as: %253Fp: + { key: "%3Fp", value: "escape_upper_q", replaceOnly: true }, + // Internally interpreted as: %253fp: + { key: "%3fp", value: "escape_lower_q", replaceOnly: true }, + // Internally interpreted as: %26p: + { key: "&p", value: "rawa", replaceOnly: true }, + // Internally interpreted as: %2526p: + { key: "%26p", value: "escape_a", replaceOnly: true }, + ], + }, + }); + await testFetch( + "http://from/?x&x&?x", + "http://dest/?x&x&?x", + "queryTransform does not match the '?' or '&' separators" + ); + await testFetch( + "http://from/??p&&p&?p", + "http://dest/??p&&p&?p", + "queryTransform cannot match literal '?p' because it is not urlencoded" + ); + await testFetch( + "http://from/?%3Fp", + "http://dest/?%3Fp=rawq", + "queryTransform matches already-urlencoded '%3Fp' with raw '?p'" + ); + await testFetch( + "http://from/?%3fp", + "http://dest/?%3fp", + "queryTransform cannot match non-canonical percent encoding (lowercase)" + ); + await testFetch( + "http://from/?%253fp&%253Fp", + "http://dest/?%253fp=escape_lower_q&%253Fp=escape_upper_q", + "queryTransform matches double-urlencoded '?p' with single-encoded '?p'" + ); + await testFetch( + "http://from/?%26p", + "http://dest/?%26p=rawa", + "queryTransform matches already-urlencoded '%26p' with raw '&p'" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_fragment() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + // Note: not using testFetch because it cannot see fragment changes. + const { setRedirectTransform, testNavigate } = dnrTestUtils; + + await setRedirectTransform({ fragment: "" }); + await testNavigate( + "http://user:pass@from:777/path?query#ref", + "http://user:pass@dest:777/path?query", + "fragment cleared from URL with embedded credentials" + ); + + await setRedirectTransform({ fragment: "#new" }); + await testNavigate("http://from/", "http://dest/#new", "fragment added"); + await testNavigate( + "http://from/#ref", + "http://dest/#new", + "fragment changed" + ); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function test_redirect_transform_failed_at_runtime() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { setRedirectTransform } = dnrTestUtils; + + // Maximum length of a UTL is 1048576 (network.standard-url.max-length). + const network_standard_url_max_length = 1048576; + // updateSessionRules does some validation on the limit (as seen by + // validate_action_redirect_transform in test_ext_dnr_session_rules.js), + // but it is still possible to pass validation and fail in practice when + // the existing URL + new component exceeds the limit. + const VERY_LONG_STRING = "x".repeat(network_standard_url_max_length - 20); + + // Like testFetch, except truncates URLs in log messages to avoid logspam. + async function testFetchPossiblyLongUrl(from, to, body, description) { + let res = await fetch(from); + const shortx = s => s.replace(/x{10,}/g, xxx => `x{${xxx.length}}`); + // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam. + browser.test.assertEq(shortx(to), shortx(res.url), description); + browser.test.assertEq(body, await res.text(), "expected body"); + } + + await setRedirectTransform({ query: "?" + VERY_LONG_STRING }); + await testFetchPossiblyLongUrl( + "http://from/short", + `http://dest/short?${VERY_LONG_STRING}`, + // Somehow the httpd server raises NS_ERROR_MALFORMED_URI when it tries + // to use newURI to parse the received URL. But the server responding + // with that implies that the redirect was successful, so for the + // purpose of this test, that response is acceptable. + "Bad request\n", + "Can redirect to URL near (but not over) url max-length" + ); + + // This check confirms that not only does the request not redirect to + // an invalid URL, but also that the request does not somehow end up in + // an infinite redirect loop. + await testFetchPossiblyLongUrl( + "http://from/1234567890_1234567890", + "http://from/1234567890_1234567890", + "GOOD_RESPONSE", + "Redirect to URL over max length is ignored; request continues" + ); + + browser.test.notifyPass(); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js new file mode 100644 index 0000000000..a34a8a070e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_session_rules.js @@ -0,0 +1,985 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", +}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + dnrTestUtils.makeRuleInput = id => { + return { + id, + condition: {}, + action: { type: "block" }, + }; + }; + dnrTestUtils.makeRuleOutput = id => { + return { + id, + condition: { + urlFilter: null, + regexFilter: null, + isUrlFilterCaseSensitive: null, + initiatorDomains: null, + excludedInitiatorDomains: null, + requestDomains: null, + excludedRequestDomains: null, + resourceTypes: null, + excludedResourceTypes: null, + requestMethods: null, + excludedRequestMethods: null, + domainType: null, + tabIds: null, + excludedTabIds: null, + }, + action: { + type: "block", + redirect: null, + requestHeaders: null, + responseHeaders: null, + }, + priority: 1, + }; + }; + + function serializeForLog(rule) { + // JSON-stringify, but drop null values (replacing them with undefined + // causes JSON.stringify to drop them), so that optional keys with the null + // values are hidden. + let str = JSON.stringify(rule, rep => rep ?? undefined); + // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam. + str = str.replace(/x{10,}/g, xxx => `x{${xxx.length}}`); + return str; + } + + async function testInvalidRule(rule, expectedError, isSchemaError) { + if (isSchemaError) { + // Schema validation error = thrown error instead of a rejection. + browser.test.assertThrows( + () => dnr.updateSessionRules({ addRules: [rule] }), + expectedError, + `Rule should be invalid (schema-validated): ${serializeForLog(rule)}` + ); + } else { + await browser.test.assertRejects( + dnr.updateSessionRules({ addRules: [rule] }), + expectedError, + `Rule should be invalid: ${serializeForLog(rule)}` + ); + } + } + async function testInvalidCondition(condition, expectedError, isSchemaError) { + await testInvalidRule( + { id: 1, condition, action: { type: "block" } }, + expectedError, + isSchemaError + ); + } + async function testInvalidAction(action, expectedError, isSchemaError) { + await testInvalidRule( + { id: 1, condition: {}, action }, + expectedError, + isSchemaError + ); + } + + // The tests in this file merely verify whether rule registration and + // retrieval works. test_ext_dnr_testMatchOutcome.js checks rule evaluation. + async function testValidRule(rule) { + await dnr.updateSessionRules({ addRules: [rule] }); + + // Default rule with null for optional fields. + const expectedRule = dnrTestUtils.makeRuleOutput(); + expectedRule.id = rule.id; + Object.assign(expectedRule.condition, rule.condition); + Object.assign(expectedRule.action, rule.action); + if (rule.action.redirect) { + expectedRule.action.redirect = { + extensionPath: null, + url: null, + transform: null, + regexSubstitution: null, + ...rule.action.redirect, + }; + if (rule.action.redirect.transform) { + expectedRule.action.redirect.transform = { + scheme: null, + username: null, + password: null, + host: null, + port: null, + path: null, + query: null, + queryTransform: null, + fragment: null, + ...rule.action.redirect.transform, + }; + if (rule.action.redirect.transform.queryTransform) { + const qt = { + removeParams: null, + addOrReplaceParams: null, + ...rule.action.redirect.transform.queryTransform, + }; + if (qt.addOrReplaceParams) { + qt.addOrReplaceParams = qt.addOrReplaceParams.map(v => ({ + key: null, + value: null, + replaceOnly: false, + ...v, + })); + } + expectedRule.action.redirect.transform.queryTransform = qt; + } + } + } + if (rule.action.requestHeaders) { + expectedRule.action.requestHeaders = rule.action.requestHeaders.map( + h => ({ header: null, operation: null, value: null, ...h }) + ); + } + if (rule.action.responseHeaders) { + expectedRule.action.responseHeaders = rule.action.responseHeaders.map( + h => ({ header: null, operation: null, value: null, ...h }) + ); + } + + browser.test.assertDeepEq( + [expectedRule], + await dnr.getSessionRules(), + "Rule should be valid" + ); + + await dnr.updateSessionRules({ removeRuleIds: [rule.id] }); + } + async function testValidCondition(condition) { + await testValidRule({ id: 1, condition, action: { type: "block" } }); + } + async function testValidAction(action) { + await testValidRule({ id: 1, condition: {}, action }); + } + + Object.assign(dnrTestUtils, { + testInvalidRule, + testInvalidCondition, + testInvalidAction, + testValidRule, + testValidCondition, + testValidAction, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ background, unloadTestAtEnd = true }) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + if (unloadTestAtEnd) { + await extension.unload(); + } + return extension; +} + +add_task(async function register_and_retrieve_session_rules() { + let extension = await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + // Rules input to updateSessionRules: + const RULE_1234_IN = dnrTestUtils.makeRuleInput(1234); + const RULE_4321_IN = dnrTestUtils.makeRuleInput(4321); + const RULE_9001_IN = dnrTestUtils.makeRuleInput(9001); + // Rules expected to be returned by getSessionRules: + const RULE_1234_OUT = dnrTestUtils.makeRuleOutput(1234); + const RULE_4321_OUT = dnrTestUtils.makeRuleOutput(4321); + const RULE_9001_OUT = dnrTestUtils.makeRuleOutput(9001); + + await dnr.updateSessionRules({ + // Deliberately rule 4321 before 1234, see next getSessionRules test. + addRules: [RULE_4321_IN, RULE_1234_IN], + removeRuleIds: [1234567890], // Invalid rules should be ignored. + }); + browser.test.assertDeepEq( + // Order is same as the original input. + [RULE_4321_OUT, RULE_1234_OUT], + await dnr.getSessionRules(), + "getSessionRules() returns all registered session rules" + ); + + await browser.test.assertRejects( + dnr.updateSessionRules({ + addRules: [RULE_9001_IN, RULE_1234_IN], + removeRuleIds: [RULE_4321_IN.id], + }), + "Duplicate rule ID: 1234", + "updateSessionRules of existing rule without removeRuleIds should fail" + ); + browser.test.assertDeepEq( + [RULE_4321_OUT, RULE_1234_OUT], + await dnr.getSessionRules(), + "session rules should not be changed if an error has occurred" + ); + + // From [4321,1234] to [1234,9001,4321]; 4321 moves to the end because + // the rule is deleted before inserted, NOT updated in-place. + await dnr.updateSessionRules({ + addRules: [RULE_9001_IN, RULE_4321_IN], + removeRuleIds: [RULE_4321_IN.id], + }); + browser.test.assertDeepEq( + [RULE_1234_OUT, RULE_9001_OUT, RULE_4321_OUT], + await dnr.getSessionRules(), + "existing session rule ID can be re-used for a new rule" + ); + + await dnr.updateSessionRules({ + removeRuleIds: [RULE_1234_IN.id, RULE_4321_IN.id, RULE_9001_IN.id], + }); + browser.test.assertDeepEq( + [], + await dnr.getSessionRules(), + "deleted all rules" + ); + + browser.test.notifyPass(); + }, + unloadTestAtEnd: false, + }); + + const realExtension = extension.extension; + Assert.ok( + ExtensionDNR.getRuleManager(realExtension, /* createIfMissing= */ false), + "Rule manager exists before unload" + ); + await extension.unload(); + Assert.ok( + !ExtensionDNR.getRuleManager(realExtension, /* createIfMissing= */ false), + "Rule manager erased after unload" + ); +}); + +add_task(async function validate_resourceTypes() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { + testInvalidCondition, + testInvalidRule, + testValidRule, + testValidCondition, + } = dnrTestUtils; + + await testInvalidCondition( + { resourceTypes: ["font", "image"], excludedResourceTypes: ["image"] }, + "resourceTypes and excludedResourceTypes should not overlap" + ); + await testInvalidCondition( + { resourceTypes: [], excludedResourceTypes: ["image"] }, + /resourceTypes: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testValidCondition({ + resourceTypes: ["font"], + excludedResourceTypes: ["image"], + }); + await testValidCondition({ + resourceTypes: ["font"], + excludedResourceTypes: [], + }); + + // Validation specific to allowAllRequests + await testInvalidRule( + { + id: 1, + condition: {}, + action: { type: "allowAllRequests" }, + }, + "An allowAllRequests rule must have a non-empty resourceTypes array" + ); + await testInvalidRule( + { + id: 1, + condition: { resourceTypes: [] }, + action: { type: "allowAllRequests" }, + }, + /resourceTypes: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidRule( + { + id: 1, + condition: { resourceTypes: ["main_frame", "image"] }, + action: { type: "allowAllRequests" }, + }, + "An allowAllRequests rule may only include main_frame/sub_frame in resourceTypes" + ); + await testValidRule({ + id: 1, + condition: { resourceTypes: ["main_frame"] }, + action: { type: "allowAllRequests" }, + }); + await testValidRule({ + id: 1, + condition: { resourceTypes: ["sub_frame"] }, + action: { type: "allowAllRequests" }, + }); + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_requestMethods() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + await testInvalidCondition( + { requestMethods: ["get"], excludedRequestMethods: ["post", "get"] }, + "requestMethods and excludedRequestMethods should not overlap" + ); + await testInvalidCondition( + { requestMethods: [] }, + /requestMethods: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidCondition( + { requestMethods: ["GET"] }, + "request methods must be in lower case" + ); + await testInvalidCondition( + { excludedRequestMethods: ["PUT"] }, + "request methods must be in lower case" + ); + await testValidCondition({ excludedRequestMethods: [] }); + await testValidCondition({ + requestMethods: ["get", "head"], + excludedRequestMethods: ["post"], + }); + await testValidCondition({ + requestMethods: ["connect", "delete", "options", "patch", "put", "xxx"], + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_tabIds() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + await testInvalidCondition( + { tabIds: [1], excludedTabIds: [1] }, + "tabIds and excludedTabIds should not overlap" + ); + await testInvalidCondition( + { tabIds: [] }, + /tabIds: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testValidCondition({ excludedTabIds: [] }); + await testValidCondition({ tabIds: [-1, 0, 1], excludedTabIds: [2] }); + await testValidCondition({ tabIds: [Number.MAX_SAFE_INTEGER] }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_domains() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + await testInvalidCondition( + { requestDomains: [] }, + /requestDomains: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidCondition( + { initiatorDomains: [] }, + /initiatorDomains: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + // The include and exclude overlaps, but the validator doesn't reject it: + await testValidCondition({ + requestDomains: ["example.com"], + excludedRequestDomains: ["example.com"], + initiatorDomains: ["example.com"], + excludedInitiatorDomains: ["example.com"], + }); + await testValidCondition({ + excludedRequestDomains: [], + excludedInitiatorDomains: [], + }); + + // "null" is valid as a way to match an opaque initiator. + await testInvalidCondition( + { requestDomains: [null] }, + /requestDomains\.0: Expected string instead of null/, + /* isSchemaError */ true + ); + await testValidCondition({ requestDomains: ["null"] }); + + // IPv4 adress should be 4 digits separated by a dot. + await testInvalidCondition( + { requestDomains: ["1.2"] }, + /requestDomains\.0: Error: Invalid domain 1.2/, + /* isSchemaError */ true + ); + await testValidCondition({ requestDomains: ["0.0.1.2"] }); + + // IPv6 should be wrapped in brackets. + await testInvalidCondition( + { requestDomains: ["::1"] }, + /requestDomains\.0: Error: Invalid domain ::1/, + /* isSchemaError */ true + ); + // IPv6 addresses cannot contain dots. + await testInvalidCondition( + { requestDomains: ["[::ffff:127.0.0.1]"] }, + /requestDomains\.0: Error: Invalid domain \[::ffff:127\.0\.0\.1\]/, + /* isSchemaError */ true + ); + await testValidCondition({ + // "[::ffff:7f00:1]" is the canonical form of "[::ffff:127.0.0.1]". + requestDomains: ["[::1]", "[::ffff:7f00:1]"], + }); + + // International Domain Names should be punycode-encoded. + await testInvalidCondition( + { requestDomains: ["straß.de"] }, + /requestDomains\.0: Error: Invalid domain straß.de/, + /* isSchemaError */ true + ); + await testValidCondition({ requestDomains: ["xn--stra-yna.de"] }); + + // Domain may not contain a port. + await testInvalidCondition( + { requestDomains: ["a.com:1234"] }, + /requestDomains\.0: Error: Invalid domain a.com:1234/, + /* isSchemaError */ true + ); + // Upper case is not canonical. + await testInvalidCondition( + { requestDomains: ["UPPERCASE"] }, + /requestDomains\.0: Error: Invalid domain UPPERCASE/, + /* isSchemaError */ true + ); + // URL encoded is not canonical. + await testInvalidCondition( + { requestDomains: ["ex%61mple.com"] }, + /requestDomains\.0: Error: Invalid domain ex%61mple.com/, + /* isSchemaError */ true + ); + + // Verify that the validation is applied to all domain-related keys. + for (let domainsKey of [ + "initiatorDomains", + "excludedInitiatorDomains", + "requestDomains", + "excludedRequestDomains", + ]) { + await testInvalidCondition( + { [domainsKey]: [""] }, + new RegExp(String.raw`${domainsKey}\.0: Error: Invalid domain \)`), + /* isSchemaError */ true + ); + } + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_urlFilter() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition, testValidCondition } = dnrTestUtils; + + await testInvalidCondition( + { urlFilter: "", regexFilter: "" }, + "urlFilter and regexFilter are mutually exclusive" + ); + + await testInvalidCondition( + { urlFilter: 0 }, + /urlFilter: Expected string instead of 0/, + /* isSchemaError */ true + ); + await testInvalidCondition( + { urlFilter: "" }, + "urlFilter should not be an empty string" + ); + await testInvalidCondition( + { urlFilter: "||*" }, + "urlFilter should not start with '||*'" // should use '*' instead. + ); + await testInvalidCondition( + { urlFilter: "||*/" }, + "urlFilter should not start with '||*'" // should use '*' instead. + ); + await testInvalidCondition( + { urlFilter: "straß.de" }, + "urlFilter should not contain non-ASCII characters" + ); + await testValidCondition({ urlFilter: "xn--stra-yna.de" }); + await testValidCondition({ urlFilter: "||xn--stra-yna.de/" }); + + // The following are all logically equivalent to "||*" (and ""), but are + // considered valid in the DNR API implemented/documented by Chrome. + await testValidCondition({ urlFilter: "*" }); + await testValidCondition({ urlFilter: "****************" }); + await testValidCondition({ urlFilter: "||" }); + await testValidCondition({ urlFilter: "|" }); + await testValidCondition({ urlFilter: "|*|" }); + await testValidCondition({ urlFilter: "^" }); + await testValidCondition({ urlFilter: null }); + + await testValidCondition({ urlFilter: "||example^" }); + await testValidCondition({ urlFilter: "||example.com" }); + await testValidCondition({ urlFilter: "||example.com/index^" }); + await testValidCondition({ urlFilter: ".gif|" }); + await testValidCondition({ urlFilter: "|https:" }); + await testValidCondition({ urlFilter: "|https:*" }); + await testValidCondition({ urlFilter: "e" }); + await testValidCondition({ urlFilter: "%80" }); + await testValidCondition({ urlFilter: "*e*" }); // FYI: same as just "e". + await testValidCondition({ urlFilter: "*e*|" }); // FYI: same as just "e". + + let validchars = ""; + for (let i = 0; i < 0x80; ++i) { + validchars += String.fromCharCode(i); + } + await testValidCondition({ urlFilter: validchars }); + // Confirming that 0x80 and up is invalid. + await testInvalidCondition( + { urlFilter: "\x80" }, + "urlFilter should not contain non-ASCII characters" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_regexFilter() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidCondition } = dnrTestUtils; + + // This check is duplicated in validate_urlFilter. + await testInvalidCondition( + { urlFilter: "", regexFilter: "" }, + "urlFilter and regexFilter are mutually exclusive" + ); + + await testInvalidCondition( + { regexFilter: /regex/ }, + /regexFilter: Expected string instead of \{\}/, + /* isSchemaError */ true + ); + + await testInvalidCondition( + { regexFilter: "" }, + "regexFilter should not be an empty string" + ); + // TODO bug 1745760: implement regexFilter support + await testInvalidCondition( + { regexFilter: "^https://example\\.com\\/" }, + "regexFilter is not supported yet" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function validate_actions() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidAction, testValidAction } = dnrTestUtils; + + await testValidAction({ type: "allow" }); + // Note: allowAllRequests is already covered in validate_resourceTypes + await testValidAction({ type: "block" }); + await testValidAction({ type: "upgradeScheme" }); + await testValidAction({ type: "block" }); + + // redirect actions, invalid cases + await testInvalidAction( + { type: "redirect" }, + "A redirect rule must have a non-empty action.redirect object" + ); + await testInvalidAction( + { type: "redirect", redirect: {} }, + "A redirect rule must have a non-empty action.redirect object" + ); + await testInvalidAction( + { type: "redirect", redirect: { extensionPath: "/", url: "http://a" } }, + "redirect.extensionPath and redirect.url are mutually exclusive" + ); + await testInvalidAction( + { type: "redirect", redirect: { extensionPath: "", url: "http://a" } }, + "redirect.extensionPath and redirect.url are mutually exclusive" + ); + await testInvalidAction( + { type: "redirect", redirect: { extensionPath: "" } }, + "redirect.extensionPath should start with a '/'" + ); + await testInvalidAction( + { + type: "redirect", + redirect: { extensionPath: browser.runtime.getURL("/") }, + }, + "redirect.extensionPath should start with a '/'" + ); + await testInvalidAction( + { type: "redirect", redirect: { url: "javascript:" } }, + /Access denied for URL javascript:/, + /* isSchemaError */ true + ); + await testInvalidAction( + { type: "redirect", redirect: { url: "JAVASCRIPT:// Hmmm" } }, + /Access denied for URL javascript:\/\/ Hmmm/, + /* isSchemaError */ true + ); + await testInvalidAction( + { type: "redirect", redirect: { url: "about:addons" } }, + /Access denied for URL about:addons/, + /* isSchemaError */ true + ); + // TODO bug 1622986: allow redirects to data:-URLs. + await testInvalidAction( + { type: "redirect", redirect: { url: "data:," } }, + /Access denied for URL data:,/, + /* isSchemaError */ true + ); + + // redirect actions, valid cases + await testValidAction({ + type: "redirect", + redirect: { extensionPath: "/foo.txt" }, + }); + await testValidAction({ + type: "redirect", + redirect: { url: "https://example.com/" }, + }); + await testValidAction({ + type: "redirect", + redirect: { url: browser.runtime.getURL("/") }, + }); + await testValidAction({ + type: "redirect", + redirect: { transform: {} }, + }); + // redirect.transform is validated in validate_action_redirect_transform. + + // modifyHeaders actions, invalid cases + await testInvalidAction( + { type: "modifyHeaders" }, + "A modifyHeaders rule must have a non-empty requestHeaders or modifyHeaders list" + ); + await testInvalidAction( + { type: "modifyHeaders", requestHeaders: [] }, + /requestHeaders: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidAction( + { type: "modifyHeaders", responseHeaders: [] }, + /responseHeaders: Array requires at least 1 items; you have 0/, + /* isSchemaError */ true + ); + await testInvalidAction( + { + type: "modifyHeaders", + requestHeaders: [{ header: "", operation: "remove" }], + }, + "header must be non-empty" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "", operation: "remove" }], + }, + "header must be non-empty" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "append" }], + }, + "value is required for operations append/set" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "set" }], + }, + "value is required for operations append/set" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "remove", value: "x" }], + }, + "value must not be provided for operation remove" + ); + await testInvalidAction( + { + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "REMOVE", value: "x" }], + }, + /operation: Invalid enumeration value "REMOVE"/, + /* isSchemaError */ true + ); + + // modifyHeaders actions, valid cases + await testValidAction({ + type: "modifyHeaders", + requestHeaders: [{ header: "x", operation: "set", value: "x" }], + }); + await testValidAction({ + type: "modifyHeaders", + responseHeaders: [{ header: "x", operation: "set", value: "x" }], + }); + await testValidAction({ + type: "modifyHeaders", + requestHeaders: [{ header: "y", operation: "set", value: "y" }], + responseHeaders: [{ header: "z", operation: "set", value: "z" }], + }); + await testValidAction({ + type: "modifyHeaders", + requestHeaders: [ + { header: "reqh", operation: "set", value: "b" }, + // Note: contrary to Chrome, we support "append" for requestHeaders: + // https://bugzilla.mozilla.org/show_bug.cgi?id=1797404#c1 + { header: "reqh", operation: "append", value: "b" }, + { header: "reqh", operation: "remove" }, + ], + responseHeaders: [ + { header: "resh", operation: "set", value: "b" }, + { header: "resh", operation: "append", value: "b" }, + { header: "resh", operation: "remove" }, + ], + }); + + await testInvalidAction( + { type: "MODIFYHEADERS" }, + /type: Invalid enumeration value "MODIFYHEADERS"/, + /* isSchemaError */ true + ); + + browser.test.notifyPass(); + }, + }); +}); + +// This test task only verifies that a redirect transform is validated upon +// registration. A transform can result in an invalid redirect despite passing +// validation (see e.g. VERY_LONG_STRING below). +// test_ext_dnr_redirect_transform.js will test the behavior of such cases. +add_task(async function validate_action_redirect_transform() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testInvalidAction, testValidAction } = dnrTestUtils; + + const GENERIC_TRANSFORM_ERROR = + "redirect.transform does not describe a valid URL transformation"; + + const testValidTransform = transform => + testValidAction({ type: "redirect", redirect: { transform } }); + const testInvalidTransform = (transform, expectedError, isSchemaError) => + testInvalidAction( + { type: "redirect", redirect: { transform } }, + expectedError ?? GENERIC_TRANSFORM_ERROR, + isSchemaError + ); + + // Maximum length of a UTL is 1048576 (network.standard-url.max-length). + // Since URLs have other characters (separators), using VERY_LONG_STRING + // anywhere in a transform should be rejected. Note that this is mainly + // to verify that there is some bounds check on the URL. It is possible + // to generate a transform that is borderline valid at validation time, + // but invalid when applied to an existing longer URL. + const VERY_LONG_STRING = "x".repeat(1048576); + + // An empty transformation is still valid. + await testValidTransform({}); + + // redirect.transform.scheme + await testValidTransform({ scheme: "http" }); + await testValidTransform({ scheme: "https" }); + await testValidTransform({ scheme: "moz-extension" }); + await testInvalidTransform( + { scheme: "HTTPS" }, + /scheme: Invalid enumeration value "HTTPS"/, + /* isSchemaError */ true + ); + await testInvalidTransform( + { scheme: "javascript" }, + /scheme: Invalid enumeration value "javascript"/, + /* isSchemaError */ true + ); + // "ftp" is unsupported because support for it was dropped in Firefox. + // Chrome documents "ftp" as a supported scheme, but in practice it does + // not do anything useful, because it cannot handle ftp schemes either. + await testInvalidTransform( + { scheme: "ftp" }, + /scheme: Invalid enumeration value "ftp"/, + /* isSchemaError */ true + ); + + // redirect.transform.host + await testValidTransform({ host: "example.com" }); + await testValidTransform({ host: "example.com." }); + await testValidTransform({ host: "localhost" }); + await testValidTransform({ host: "127.0.0.1" }); + await testValidTransform({ host: "[::1]" }); + await testValidTransform({ host: "." }); + await testValidTransform({ host: "straß.de" }); + await testValidTransform({ host: "xn--stra-yna.de" }); + await testInvalidTransform({ host: "::1" }); // Invalid IPv6. + await testInvalidTransform({ host: "[]" }); // Invalid IPv6. + await testInvalidTransform({ host: "/" }); // Invalid host + await testInvalidTransform({ host: " a" }); // Invalid host + await testInvalidTransform({ host: "foo:1234" }); // Port not allowed. + await testInvalidTransform({ host: "foo:" }); // Port sep not allowed. + await testInvalidTransform({ host: "" }); // Host cannot be empty. + await testInvalidTransform({ host: VERY_LONG_STRING }); + + // redirect.transform.port + await testValidTransform({ port: "" }); // empty = strip port. + await testValidTransform({ port: "0" }); + await testValidTransform({ port: "0700" }); + await testValidTransform({ port: "65535" }); + const PORT_ERR = "redirect.transform.port should be empty or an integer"; + await testInvalidTransform({ port: "65536" }, GENERIC_TRANSFORM_ERROR); + await testInvalidTransform({ port: " 0" }, PORT_ERR); + await testInvalidTransform({ port: "0 " }, PORT_ERR); + await testInvalidTransform({ port: "0." }, PORT_ERR); + await testInvalidTransform({ port: "0x1" }, PORT_ERR); + await testInvalidTransform({ port: "1.2" }, PORT_ERR); + await testInvalidTransform({ port: "-1" }, PORT_ERR); + await testInvalidTransform({ port: "a" }, PORT_ERR); + // A naive implementation of `host = hostname + ":" + port` could be + // misinterpreted as an IPv6 address. Verify that this is not the case. + await testInvalidTransform({ host: "[::1", port: "2]" }, PORT_ERR); + await testInvalidTransform({ port: VERY_LONG_STRING }, PORT_ERR); + + // redirect.transform.path + await testValidTransform({ path: "" }); // empty = strip path. + await testValidTransform({ path: "/slash" }); + await testValidTransform({ path: "/ref#ok" }); // # will be escaped. + await testValidTransform({ path: "/\n\t\x00" }); // Will all be escaped. + // A path should start with a '/', but the implementation works fine + // without it, and Chrome doesn't require it either. + await testValidTransform({ path: "noslash" }); + await testValidTransform({ path: "http://example.com/" }); + await testInvalidTransform({ path: VERY_LONG_STRING }); + + // redirect.transform.query + await testValidTransform({ query: "" }); // empty = strip query. + await testValidTransform({ query: "?suffix" }); + await testValidTransform({ query: "?ref#ok" }); // # will be escaped. + await testValidTransform({ query: "?\n\t\x00" }); // Will all be escaped. + await testInvalidTransform( + { query: "noquestionmark" }, + "redirect.transform.query should be empty or start with a '?'" + ); + await testInvalidTransform({ query: "?" + VERY_LONG_STRING }); + + // redirect.transform.queryTransform + await testInvalidTransform( + { query: "", queryTransform: {} }, + "redirect.transform.query and redirect.transform.queryTransform are mutually exclusive" + ); + await testValidTransform({ queryTransform: {} }); + await testValidTransform({ queryTransform: { removeParams: [] } }); + await testValidTransform({ queryTransform: { removeParams: ["x"] } }); + await testValidTransform({ queryTransform: { addOrReplaceParams: [] } }); + await testValidTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "k", value: "v" }], + }, + }); + await testValidTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "k", value: "v", replaceOnly: true }], + }, + }); + await testInvalidTransform({ + queryTransform: { + addOrReplaceParams: [{ key: "k", value: VERY_LONG_STRING }], + }, + }); + await testInvalidTransform( + { + queryTransform: { + addOrReplaceParams: [{ key: "k" }], + }, + }, + /addOrReplaceParams\.0: Property "value" is required/, + /* isSchemaError */ true + ); + await testInvalidTransform( + { + queryTransform: { + addOrReplaceParams: [{ value: "v" }], + }, + }, + /addOrReplaceParams\.0: Property "key" is required/, + /* isSchemaError */ true + ); + + // redirect.transform.fragment + await testValidTransform({ fragment: "" }); // empty = strip fragment. + await testValidTransform({ fragment: "#suffix" }); + await testValidTransform({ fragment: "#\n\t\x00" }); // will be escaped. + await testInvalidTransform( + { fragment: "nohash" }, + "redirect.transform.fragment should be empty or start with a '#'" + ); + await testInvalidTransform({ fragment: "#" + VERY_LONG_STRING }); + + // redirect.transform.username + await testValidTransform({ username: "" }); // empty = strip username. + await testValidTransform({ username: "username" }); + await testValidTransform({ username: "@:" }); // will be escaped. + await testInvalidTransform({ username: VERY_LONG_STRING }); + + // redirect.transform.password + await testValidTransform({ password: "" }); // empty = strip password. + await testValidTransform({ password: "pass" }); + await testValidTransform({ password: "@:" }); // will be escaped. + await testInvalidTransform({ password: VERY_LONG_STRING }); + + // All together: + await testValidTransform({ + scheme: "http", + username: "a", + password: "b", + host: "c", + port: "12345", + path: "/d", + query: "?e", + queryTransform: null, + fragment: "#f", + }); + + browser.test.notifyPass(); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js new file mode 100644 index 0000000000..7bde0cc3cd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_static_rules.js @@ -0,0 +1,1322 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionDNR: "resource://gre/modules/ExtensionDNR.sys.mjs", + ExtensionDNRStore: "resource://gre/modules/ExtensionDNRStore.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +Services.scriptloader.loadSubScript( + Services.io.newFileURI(do_get_file("head_dnr.js")).spec, + this +); + +function backgroundWithDNRAPICallHandlers() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let result; + switch (msg) { + case "getEnabledRulesets": + result = await browser.declarativeNetRequest.getEnabledRulesets(); + break; + case "getAvailableStaticRuleCount": + result = await browser.declarativeNetRequest.getAvailableStaticRuleCount(); + break; + case "testMatchOutcome": + result = await browser.declarativeNetRequest + .testMatchOutcome(...args) + .catch(err => + browser.test.fail( + `Unexpected rejection from testMatchOutcome call: ${err}` + ) + ); + break; + case "updateEnabledRulesets": + // Run (one or more than one concurrently) updateEnabledRulesets calls + // and report back the results. + result = await Promise.all( + args.map(arg => { + return browser.declarativeNetRequest + .updateEnabledRulesets(arg) + .catch(err => { + return { rejectedWithErrorMessage: err.message }; + }); + }) + ); + break; + default: + browser.test.fail(`Unexpected test message: ${msg}`); + return; + } + + browser.test.sendMessage(`${msg}:done`, result); + }); + + browser.test.sendMessage("bgpage:ready"); +} + +function getDNRExtension({ + id = "test-dnr-static-rules@test-extension", + version = "1.0", + background = backgroundWithDNRAPICallHandlers, + useAddonManager = "permanent", + rule_resources, + declarative_net_request, + files, +}) { + // Omit declarative_net_request if rule_resources isn't defined + // (because declarative_net_request fails the manifest validation + // if rule_resources is missing). + const dnr = rule_resources ? { rule_resources } : undefined; + + return { + background, + useAddonManager, + manifest: { + manifest_version: 3, + version, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + // Needed to make sure the upgraded extension will have the same id and + // same uuid (which is mapped based on the extension id). + browser_specific_settings: { + gecko: { id }, + }, + declarative_net_request: declarative_net_request + ? { ...declarative_net_request, ...(dnr ?? {}) } + : dnr, + }, + files, + }; +} + +const assertDNRTestMatchOutcome = async ( + { extension, testRequest, expected }, + assertMessage +) => { + extension.sendMessage("testMatchOutcome", testRequest); + Assert.deepEqual( + expected, + await extension.awaitMessage("testMatchOutcome:done"), + assertMessage ?? + "Got the expected matched rules from testMatchOutcome API call" + ); +}; + +const assertDNRGetAvailableStaticRuleCount = async ( + extensionTestWrapper, + expectedCount, + assertMessage +) => { + extensionTestWrapper.sendMessage("getAvailableStaticRuleCount"); + Assert.deepEqual( + await extensionTestWrapper.awaitMessage("getAvailableStaticRuleCount:done"), + expectedCount, + assertMessage ?? + "Got the expected count value from dnr.getAvailableStaticRuleCount API method" + ); +}; + +const assertDNRGetEnabledRulesets = async ( + extensionTestWrapper, + expectedRulesetIds +) => { + extensionTestWrapper.sendMessage("getEnabledRulesets"); + Assert.deepEqual( + await extensionTestWrapper.awaitMessage("getEnabledRulesets:done"), + expectedRulesetIds, + "Got the expected enabled ruleset ids from dnr.getEnabledRulesets API method" + ); +}; + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); + + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_load_static_rules() { + const ruleset1Data = [ + getDNRRule({ + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }), + ]; + const ruleset2Data = [ + getDNRRule({ + action: { type: "block" }, + condition: { resourceTypes: ["main_frame", "script"] }, + }), + ]; + + const rule_resources = [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: true, + path: "ruleset_2.json", + }, + { + id: "ruleset_3", + enabled: false, + path: "ruleset_3.json", + }, + ]; + const files = { + // Missing ruleset_3.json on purpose. + "ruleset_1.json": JSON.stringify(ruleset1Data), + "ruleset_2.json": JSON.stringify(ruleset2Data), + }; + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, files }) + ); + + await extension.startup(); + + const extUUID = extension.uuid; + + await extension.awaitMessage("bgpage:ready"); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + + info("Verify DNRStore data for the test extension"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_2"]); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data), + }); + + info("Verify matched rules using testMatchOutcome"); + const testRequestMainFrame = { + url: "https://example.com/some-dummy-url", + type: "main_frame", + }; + const testRequestScript = { + url: "https://example.com/some-dummy-url.js", + type: "script", + }; + + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestMainFrame, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }], + }, + }, + "Expect ruleset_1 to be matched on the main-frame test request" + ); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestScript, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_2" }], + }, + }, + "Expect ruleset_2 to be matched on the script test request" + ); + + info("Verify DNRStore data persisted on disk for the test extension"); + // The data will not be stored on disk until something is being changed + // from what was already available in the manifest and so in this + // test we save manually (a test for the updateEnabledRulesets will + // take care of asserting that the data has been stored automatically + // on disk when it is meant to). + await dnrStore.save(extension.extension); + + const { storeFile } = dnrStore.getFilePaths(extUUID); + + ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`); + + // force deleting the data stored in memory to confirm if it being loaded again from + // the files stored on disk. + dnrStore._data.delete(extUUID); + dnrStore._dataPromises.delete(extUUID); + + info("Verify the expected DNRStore data persisted on disk is loaded back"); + const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" + ); + const addon = await AddonManager.getAddonByID(extension.id); + await addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + await addon.enable(); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_2"]); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data), + }); + + info("Verify matched rules using testMatchOutcome"); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestMainFrame, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }], + }, + }, + "Expect ruleset_1 to be matched on the main-frame test request" + ); + + info("Verify enabled static rules updated on addon updates"); + await extension.upgrade( + getDNRExtension({ + version: "2.0", + rule_resources: [ + { + id: "ruleset_1", + enabled: false, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: true, + path: "ruleset_2.json", + }, + ], + files: { + "ruleset_2.json": JSON.stringify(ruleset2Data), + }, + }) + ); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_2"]); + await assertDNRStoreData(dnrStore, extension, { + ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data), + }); + + info("Verify matched rules using testMatchOutcome"); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestMainFrame, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_2" }], + }, + }, + "Expect ruleset_2 to be matched on the main-frame test request" + ); + + info( + "Verify enabled static rules updated on addon updates even if version in the manifest did not change" + ); + await extension.upgrade( + getDNRExtension({ + rule_resources: [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: false, + path: "ruleset_2.json", + }, + ], + files: { + "ruleset_1.json": JSON.stringify(ruleset1Data), + }, + }) + ); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + info("Verify matched rules using testMatchOutcome"); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestMainFrame, + expected: { + matchedRules: [{ ruleId: 1, rulesetId: "ruleset_1" }], + }, + }, + "Expect ruleset_2 to be matched on the main-script test request" + ); + + info( + "Verify updated addon version with no static rules but declarativeNetRequest permission granted" + ); + await extension.upgrade( + getDNRExtension({ + version: "3.0", + rule_resources: undefined, + files: {}, + }) + ); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, []); + await assertDNRStoreData(dnrStore, extension, {}); + + info("Verify matched rules using testMatchOutcome"); + await assertDNRTestMatchOutcome( + { + extension, + testRequest: testRequestScript, + expected: { + matchedRules: [], + }, + }, + "Expect no match on the script test request on test extension without no static rules" + ); + + info("Verify store file removed on addon uninstall"); + await extension.unload(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been unloaded" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been unloaded" + ); + + ok( + !(await IOUtils.exists(storeFile)), + `DNR storeFile ${storeFile} removed on addon uninstalled` + ); +}); + +add_task(async function test_load_from_corrupted_data() { + const ruleset1Data = [ + getDNRRule({ + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }), + ]; + + const rule_resources = [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + ]; + + const files = { + "ruleset_1.json": JSON.stringify(ruleset1Data), + }; + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, files }) + ); + + await extension.startup(); + + const extUUID = extension.uuid; + + await extension.awaitMessage("bgpage:ready"); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + + info("Verify DNRStore data for the test extension"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + info("Verify DNRStore data after loading corrupted store data"); + await dnrStore.save(extension.extension); + + const { storeFile } = dnrStore.getFilePaths(extUUID); + ok(await IOUtils.exists(storeFile), `DNR storeFile ${storeFile} found`); + + const nonCorruptedData = await IOUtils.readJSON(storeFile, { + decompress: true, + }); + + async function testLoadedRulesAfterDataCorruption({ + name, + asyncWriteStoreFile, + expectedCorruptFile, + }) { + info(`Tempering DNR store data: ${name}`); + + await extension.addon.disable(); + + ok( + !dnrStore._dataPromises.has(extUUID), + "DNR store read data promise cleared after the extension has been disabled" + ); + ok( + !dnrStore._data.has(extUUID), + "DNR store data cleared from memory after the extension has been disabled" + ); + + await asyncWriteStoreFile(); + + await extension.addon.enable(); + await extension.awaitMessage("bgpage:ready"); + + info("Verify DNRStore data for the test extension"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + await TestUtils.waitForCondition( + () => IOUtils.exists(`${expectedCorruptFile}`), + `Wait for the "${expectedCorruptFile}" file to have been created` + ); + + ok( + !(await IOUtils.exists(storeFile)), + "Corrupted store file expected to be removed" + ); + } + + await testLoadedRulesAfterDataCorruption({ + name: "invalid lz4 header", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "not an lz4 compressed file", { + compress: false, + }), + expectedCorruptFile: `${storeFile}.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid json data", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "invalid json data", { compress: true }), + expectedCorruptFile: `${storeFile}-1.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "empty json data", + asyncWriteStoreFile: () => + IOUtils.writeUTF8(storeFile, "{}", { compress: true }), + expectedCorruptFile: `${storeFile}-2.corrupt`, + }); + + await testLoadedRulesAfterDataCorruption({ + name: "invalid staticRulesets property type", + asyncWriteStoreFile: () => + IOUtils.writeUTF8( + storeFile, + JSON.stringify({ + schemaVersion: nonCorruptedData.schemaVersion, + extVersion: extension.extension.version, + staticRulesets: "Not an array", + }), + { compress: true } + ), + expectedCorruptFile: `${storeFile}-3.corrupt`, + }); + + await extension.unload(); +}); + +add_task(async function test_ruleset_validation() { + const invalidRulesetIdCases = [ + { + description: "empty ruleset id", + rule_resources: [ + { + // Invalid empty ruleset id. + id: "", + path: "ruleset_0.json", + enabled: true, + }, + ], + expected: [ + // Validation error emitted from the manifest schema validation. + { + message: /rule_resources\.0\.id: String "" must match/, + }, + ], + }, + { + description: "invalid ruleset id starting with '_'", + rule_resources: [ + { + // Invalid empty ruleset id. + id: "_invalid_ruleset_id", + path: "ruleset_0.json", + enabled: true, + }, + ], + expected: [ + // Validation error emitted from the manifest schema validation. + { + message: /rule_resources\.0\.id: String "_invalid_ruleset_id" must match/, + }, + ], + }, + { + description: "duplicated ruleset ids", + rule_resources: [ + { + id: "ruleset_2", + path: "ruleset_2.json", + enabled: true, + }, + { + // Duplicated ruleset id. + id: "ruleset_2", + path: "duplicated_ruleset_2.json", + enabled: true, + }, + { + id: "ruleset_3", + path: "ruleset_3.json", + enabled: true, + }, + { + // Other duplicated ruleset id. + id: "ruleset_3", + path: "duplicated_ruleset_3.json", + enabled: true, + }, + ], + // NOTE: this is currently a warning logged from onManifestEntry, and so it would actually + // fail in test harness due to the manifest warning, because it is too late at that point + // the addon is technically already starting at that point. + expectInstallFailed: false, + expected: [ + { + message: /declarative_net_request: Static ruleset ids should be unique.*: "ruleset_2" at index 1, "ruleset_3" at index 3/, + }, + ], + }, + { + description: "missing mandatory path", + rule_resources: [ + { + // Missing mandatory path. + id: "ruleset_3", + enabled: true, + }, + ], + expected: [ + { + message: /rule_resources\.0: Property "path" is required/, + }, + ], + }, + { + description: "missing mandatory id", + rule_resources: [ + { + // Missing mandatory id. + enabled: true, + path: "missing_ruleset_id.json", + }, + ], + expected: [ + { + message: /rule_resources\.0: Property "id" is required/, + }, + ], + }, + { + description: "duplicated ruleset path", + rule_resources: [ + { + id: "ruleset_2", + path: "ruleset_2.json", + enabled: true, + }, + { + // Duplicate path. + id: "ruleset_3", + path: "ruleset_2.json", + enabled: true, + }, + ], + // NOTE: we couldn't get on agreement about making this a manifest validation error, apparently Chrome doesn't validate it and doesn't + // even report any warning, and so it is logged only as an informative warning but without triggering an install failure. + expectInstallFailed: false, + expected: [ + { + message: /declarative_net_request: Static rulesets paths are not unique.*: ".*ruleset_2.json" at index 1/, + }, + ], + }, + { + description: "missing mandatory enabled", + rule_resources: [ + { + id: "ruleset_without_enabled", + path: "ruleset.json", + }, + ], + expected: [ + { + message: /rule_resources\.0: Property "enabled" is required/, + }, + ], + }, + { + description: "allows and warns additional properties", + declarative_net_request: { + unexpected_prop: true, + rule_resources: [ + { + id: "ruleset1", + path: "ruleset1.json", + enabled: false, + unexpected_prop: true, + }, + ], + }, + expectInstallFailed: false, + expected: [ + { + message: /declarative_net_request.unexpected_prop: An unexpected property was found/, + }, + { + message: /rule_resources.0.unexpected_prop: An unexpected property was found/, + }, + ], + }, + ]; + + for (const { + description, + declarative_net_request, + rule_resources, + expected, + expectInstallFailed = true, + } of invalidRulesetIdCases) { + info(`Test manifest validation: ${description}`); + let extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, declarative_net_request }) + ); + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + if (expectInstallFailed) { + await Assert.rejects( + extension.startup(), + /Install failed/, + "Expected install to fail" + ); + } else { + await extension.startup(); + await extension.awaitMessage("bgpage:ready"); + await extension.unload(); + } + ExtensionTestUtils.failOnSchemaWarnings(true); + }); + + AddonTestUtils.checkMessages(messages, { expected }); + } +}); + +add_task(async function test_updateEnabledRuleset_id_validation() { + const rule_resources = [ + { + id: "ruleset_1", + enabled: true, + path: "ruleset_1.json", + }, + { + id: "ruleset_2", + enabled: false, + path: "ruleset_2.json", + }, + ]; + + const ruleset1Data = [ + getDNRRule({ + action: { type: "allow" }, + condition: { resourceTypes: ["main_frame"] }, + }), + ]; + const ruleset2Data = [ + getDNRRule({ + action: { type: "block" }, + condition: { resourceTypes: ["main_frame", "script"] }, + }), + ]; + + const files = { + "ruleset_1.json": JSON.stringify(ruleset1Data), + "ruleset_2.json": JSON.stringify(ruleset2Data), + }; + + let extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ rule_resources, files }) + ); + + await extension.startup(); + await extension.awaitMessage("bgpage:ready"); + + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + const invalidStaticRulesetIds = [ + // The following two are reserved for session and dynamic rules. + "_session", + "_dynamic", + "ruleset_non_existing", + ]; + + for (const invalidRSId of invalidStaticRulesetIds) { + extension.sendMessage( + "updateEnabledRulesets", + // Only in rulesets to be disabled. + { disableRulesetIds: [invalidRSId] }, + // Only in rulesets to be enabled. + { enableRulesetIds: [invalidRSId] }, + // In both rulesets to be enabled and disabled. + { disableRulesetIds: [invalidRSId], enableRulesetIds: [invalidRSId] }, + // Along with existing rulesets (and expected the existing rulesets + // to stay unchanged due to the invalid ruleset ids.) + { + disableRulesetIds: [invalidRSId, "ruleset_1"], + enableRulesetIds: [invalidRSId, "ruleset_2"], + } + ); + const [ + resInDisable, + resInEnable, + resInEnableAndDisable, + resInSameRequestAsValid, + ] = await extension.awaitMessage("updateEnabledRulesets:done"); + await Assert.rejects( + Promise.reject(resInDisable?.rejectedWithErrorMessage), + new RegExp(`Invalid ruleset id: "${invalidRSId}"`), + `Got the expected rejection on invalid ruleset id "${invalidRSId}" in disableRulesetIds` + ); + await Assert.rejects( + Promise.reject(resInEnable?.rejectedWithErrorMessage), + new RegExp(`Invalid ruleset id: "${invalidRSId}"`), + `Got the expected rejection on invalid ruleset id "${invalidRSId}" in enableRulesetIds` + ); + await Assert.rejects( + Promise.reject(resInEnableAndDisable?.rejectedWithErrorMessage), + new RegExp(`Invalid ruleset id: "${invalidRSId}"`), + `Got the expected rejection on invalid ruleset id "${invalidRSId}" in both enable/disableRulesetIds` + ); + await Assert.rejects( + Promise.reject(resInSameRequestAsValid?.rejectedWithErrorMessage), + new RegExp(`Invalid ruleset id: "${invalidRSId}"`), + `Got the expected rejection on invalid ruleset id "${invalidRSId}" along with valid ruleset ids` + ); + } + + // Confirm that the expected rulesets didn't change neither. + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + await assertDNRStoreData(dnrStore, extension, { + ruleset_1: getSchemaNormalizedRules(extension, ruleset1Data), + }); + + // - List the same ruleset ids more than ones is expected to work and + // to be resulting in the same set of rules being enabled + // - Disabling and Enabling the same ruleset id should result in the + // ruleset being enabled. + await extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: [ + "ruleset_1", + "ruleset_1", + "ruleset_2", + "ruleset_2", + "ruleset_2", + ], + enableRulesetIds: ["ruleset_2", "ruleset_2"], + }); + Assert.deepEqual( + await extension.awaitMessage("updateEnabledRulesets:done"), + [undefined], + "Expect the updateEnabledRulesets to result successfully" + ); + + await assertDNRGetEnabledRulesets(extension, ["ruleset_2"]); + await assertDNRStoreData(dnrStore, extension, { + ruleset_2: getSchemaNormalizedRules(extension, ruleset2Data), + }); + + await extension.unload(); +}); + +add_task(async function test_getAvailableStaticRulesCountAndLimits() { + // NOTE: this test is going to load and validate the maximum amount of static rules + // that an extension can enable, which on slower builds (in particular in tsan builds, + // e.g. see Bug 1803801) have a higher chance that the test extension may have hit the + // idle timeout and being suspended by the time the test is going to trigger API method + // calls through test API events (which do not expect the lifetime of the event page). + Services.prefs.setBoolPref("extensions.background.idle.enabled", false); + + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + const { GUARANTEED_MINIMUM_STATIC_RULES } = ExtensionDNR.limits; + equal( + typeof GUARANTEED_MINIMUM_STATIC_RULES, + "number", + "Expect GUARANTEED_MINIMUM_STATIC_RULES to be a number" + ); + + const availableStaticRulesCount = GUARANTEED_MINIMUM_STATIC_RULES; + + const rule_resources = [ + { + id: "ruleset_0", + path: "/ruleset_0.json", + enabled: true, + }, + { + id: "ruleset_1", + path: "/ruleset_1.json", + enabled: true, + }, + // A ruleset initially disabled (to make sure it doesn't count for the + // rules count limit). + { + id: "ruleset_disabled", + path: "/ruleset_disabled.json", + enabled: false, + }, + // A ruleset including an invalid rule and valid rule. + { + id: "ruleset_withInvalid", + path: "/ruleset_withInvalid.json", + enabled: false, + }, + // An empty ruleset (to make sure it can still be enabled/disabled just fine, + // e.g. in case on some browser version all rules are technically invalid). + { + id: "ruleset_empty", + path: "/ruleset_empty.json", + enabled: false, + }, + ]; + + const files = {}; + const rules = {}; + + const rulesetDisabledData = [getDNRRule({ id: 1 })]; + const ruleValid = getDNRRule({ id: 2, action: { type: "allow" } }); + const rulesetWithInvalidData = [ + getDNRRule({ id: 1, action: { type: "invalid_action" } }), + ruleValid, + ]; + + rules.ruleset_0 = [getDNRRule({ id: 1 }), getDNRRule({ id: 2 })]; + + rules.ruleset_1 = []; + for (let i = 0; i < availableStaticRulesCount; i++) { + rules.ruleset_1.push(getDNRRule({ id: i + 1 })); + } + + for (const [k, v] of Object.entries(rules)) { + files[`${k}.json`] = JSON.stringify(v); + } + files[`ruleset_disabled.json`] = JSON.stringify(rulesetDisabledData); + files[`ruleset_withInvalid.json`] = JSON.stringify(rulesetWithInvalidData); + files[`ruleset_empty.json`] = JSON.stringify([]); + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + id: "dnr-getAvailable-count-@mochitest", + rule_resources, + files, + }) + ); + + await extension.startup(); + await extension.awaitMessage("bgpage:ready"); + + const expectedEnabledRulesets = {}; + expectedEnabledRulesets.ruleset_0 = getSchemaNormalizedRules( + extension, + rules.ruleset_0 + ); + + info( + "Expect ruleset_1 to not be enabled because along with ruleset_0 exceeded the static rules count limit" + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + await assertDNRGetAvailableStaticRuleCount( + extension, + availableStaticRulesCount - rules.ruleset_0.length, + "Got the available static rule count on ruleset_0 initially enabled" + ); + + // Try to enable ruleset_1 again from the API method. + extension.sendMessage("updateEnabledRulesets", { + enableRulesetIds: ["ruleset_1"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + + info( + "Expect ruleset_1 to not be enabled because still exceeded the static rules count limit" + ); + await assertDNRGetEnabledRulesets(extension, ["ruleset_0"]); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + await assertDNRGetAvailableStaticRuleCount( + extension, + availableStaticRulesCount - rules.ruleset_0.length, + "Got the available static rule count on ruleset_0 still the only one enabled" + ); + + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset_0"], + enableRulesetIds: ["ruleset_1"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + + info("Expect ruleset_1 to be enabled along with disabling ruleset_0"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + delete expectedEnabledRulesets.ruleset_0; + expectedEnabledRulesets.ruleset_1 = getSchemaNormalizedRules( + extension, + rules.ruleset_1 + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets, { + // Assert total amount of expected rules and only the first and last rule + // individually, to avoid generating a huge amount of logs and potential + // timeout failures on slower builds. + assertIndividualRules: false, + }); + + await assertDNRGetAvailableStaticRuleCount( + extension, + 0, + "Expect no additional static rules count available when ruleset_1 is enabled" + ); + + info( + "Expect ruleset_disabled to stay disabled because along with ruleset_1 exceeeds the limits" + ); + extension.sendMessage("updateEnabledRulesets", { + enableRulesetIds: ["ruleset_disabled"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1"]); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets, { + // Assert total amount of expected rules and only the first and last rule + // individually, to avoid generating a huge amount of logs and potential + // timeout failures on slower builds. + assertIndividualRules: false, + }); + await assertDNRGetAvailableStaticRuleCount( + extension, + 0, + "Expect no additional static rules count available" + ); + + info("Expect ruleset_empty to be enabled despite having reached the limit"); + extension.sendMessage("updateEnabledRulesets", { + enableRulesetIds: ["ruleset_empty"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_1", "ruleset_empty"]); + await assertDNRStoreData( + dnrStore, + extension, + { + ...expectedEnabledRulesets, + ruleset_empty: [], + }, + // Assert total amount of expected rules and only the first and last rule + // individually, to avoid generating a huge amount of logs and potential + // timeout failures on slower builds. + { assertIndividualRules: false } + ); + await assertDNRGetAvailableStaticRuleCount( + extension, + 0, + "Expect no additional static rules count available" + ); + + info("Expect invalid rules to not be counted towards the limits"); + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset_1", "ruleset_empty"], + enableRulesetIds: ["ruleset_withInvalid"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + await assertDNRGetEnabledRulesets(extension, ["ruleset_withInvalid"]); + await assertDNRStoreData(dnrStore, extension, { + // Only the valid rule has been actually loaded, and the invalid one + // ignored. + ruleset_withInvalid: [ruleValid], + }); + await assertDNRGetAvailableStaticRuleCount( + extension, + availableStaticRulesCount - 1, + "Expect only valid rules to be counted" + ); + + await extension.unload(); + + Services.prefs.clearUserPref("extensions.background.idle.enabled"); +}); + +add_task(async function test_static_rulesets_limits() { + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + + const getRulesetManifestData = (rulesetNumber, enabled) => { + return { + id: `ruleset_${rulesetNumber}`, + enabled, + path: `ruleset_${rulesetNumber}.json`, + }; + }; + const { + MAX_NUMBER_OF_STATIC_RULESETS, + MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + } = ExtensionDNR.limits; + + equal( + typeof MAX_NUMBER_OF_STATIC_RULESETS, + "number", + "Expect MAX_NUMBER_OF_STATIC_RULESETS to be a number" + ); + equal( + typeof MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + "number", + "Expect MAX_NUMBER_OF_ENABLED_STATIC_RULESETS to be a number" + ); + ok( + MAX_NUMBER_OF_STATIC_RULESETS > MAX_NUMBER_OF_ENABLED_STATIC_RULESETS, + "Expect MAX_NUMBER_OF_STATIC_RULESETS to be greater" + ); + + const rules = [getDNRRule()]; + + const rule_resources = []; + const files = {}; + for (let i = 0; i < MAX_NUMBER_OF_STATIC_RULESETS + 1; i++) { + const enabled = i < MAX_NUMBER_OF_ENABLED_STATIC_RULESETS + 1; + files[`ruleset_${i}.json`] = JSON.stringify(rules); + rule_resources.push(getRulesetManifestData(i, enabled)); + } + + let extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + rule_resources, + files, + }) + ); + + const expectedEnabledRulesets = {}; + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("bgpage:ready"); + + for (let i = 0; i < MAX_NUMBER_OF_ENABLED_STATIC_RULESETS; i++) { + expectedEnabledRulesets[`ruleset_${i}`] = getSchemaNormalizedRules( + extension, + rules + ); + } + + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + // Warnings emitted from the manifest schema validation. + { + message: /declarative_net_request: Static rulesets are exceeding the MAX_NUMBER_OF_STATIC_RULESETS limit/, + }, + { + message: /declarative_net_request: Enabled static rulesets are exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS limit .* "ruleset_10"/, + }, + // Error reported on the browser console as part of loading enabled rulesets) + // on enabled rulesets being ignored because exceeding the limit. + { + message: /Ignoring enabled static ruleset exceeding the MAX_NUMBER_OF_ENABLED_STATIC_RULESETS .* "ruleset_10"/, + }, + ], + }); + + info( + "Verify updateEnabledRulesets reject when the request is exceeding the enabled rulesets count limit" + ); + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset_0"], + enableRulesetIds: ["ruleset_10", "ruleset_11"], + }); + + await Assert.rejects( + extension.awaitMessage("updateEnabledRulesets:done").then(results => { + if (results[0].rejectedWithErrorMessage) { + return Promise.reject(new Error(results[0].rejectedWithErrorMessage)); + } + return results[0]; + }), + /updatedEnabledRulesets request is exceeding MAX_NUMBER_OF_ENABLED_STATIC_RULESETS/, + "Expected rejection on updateEnabledRulesets exceeting enabled rulesets count limit" + ); + + // Confirm that the expected rulesets didn't change neither. + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + info( + "Verify updateEnabledRulesets applies the expected changes when resolves successfully" + ); + extension.sendMessage( + "updateEnabledRulesets", + { + disableRulesetIds: ["ruleset_0"], + enableRulesetIds: ["ruleset_10"], + }, + { + disableRulesetIds: ["ruleset_10"], + enableRulesetIds: ["ruleset_11"], + } + ); + await extension.awaitMessage("updateEnabledRulesets:done"); + + // Expect ruleset_0 disabled, ruleset_10 to be enabled but then disabled by the + // second update queued after the first one, and ruleset_11 to be enabled. + delete expectedEnabledRulesets.ruleset_0; + expectedEnabledRulesets.ruleset_11 = getSchemaNormalizedRules( + extension, + rules + ); + + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + // Ensure all changes were stored and reloaded from disk store and the + // DNR store update queue can accept new updates. + info("Verify static rules load and updates after extension is restarted"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + extension.sendMessage("updateEnabledRulesets", { + disableRulesetIds: ["ruleset_11"], + }); + await extension.awaitMessage("updateEnabledRulesets:done"); + delete expectedEnabledRulesets.ruleset_11; + await assertDNRGetEnabledRulesets( + extension, + Array.from(Object.keys(expectedEnabledRulesets)) + ); + await assertDNRStoreData(dnrStore, extension, expectedEnabledRulesets); + + await extension.unload(); +}); + +add_task(async function test_tabId_conditions_invalid_in_static_rules() { + const ruleset1_with_tabId_condition = [ + getDNRRule({ id: 1, condition: { tabIds: [1] } }), + getDNRRule({ id: 3, condition: { urlFilter: "valid-ruleset1-rule" } }), + ]; + + const ruleset2_with_excludeTabId_condition = [ + getDNRRule({ id: 2, condition: { excludedTabIds: [1] } }), + getDNRRule({ id: 3, condition: { urlFilter: "valid-ruleset2-rule" } }), + ]; + + const rule_resources = [ + { + id: "ruleset1_with_tabId_condition", + enabled: true, + path: "ruleset1.json", + }, + { + id: "ruleset2_with_excludeTabId_condition", + enabled: true, + path: "ruleset2.json", + }, + ]; + + const files = { + "ruleset1.json": JSON.stringify(ruleset1_with_tabId_condition), + "ruleset2.json": JSON.stringify(ruleset2_with_excludeTabId_condition), + }; + + const extension = ExtensionTestUtils.loadExtension( + getDNRExtension({ + id: "tabId-invalid-in-session-rules@mochitest", + rule_resources, + files, + }) + ); + + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("bgpage:ready"); + await assertDNRGetEnabledRulesets(extension, [ + "ruleset1_with_tabId_condition", + "ruleset2_with_excludeTabId_condition", + ]); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { + message: /"ruleset1_with_tabId_condition": tabIds and excludedTabIds can only be specified in session rules/, + }, + { + message: /"ruleset2_with_excludeTabId_condition": tabIds and excludedTabIds can only be specified in session rules/, + }, + ], + }); + + info("Expect the invalid rule to not be enabled"); + const dnrStore = ExtensionDNRStore._getStoreForTesting(); + // Expect the two valid rules to have been loaded as expected. + await assertDNRStoreData(dnrStore, extension, { + ruleset1_with_tabId_condition: getSchemaNormalizedRules(extension, [ + ruleset1_with_tabId_condition[1], + ]), + ruleset2_with_excludeTabId_condition: getSchemaNormalizedRules(extension, [ + ruleset2_with_excludeTabId_condition[1], + ]), + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js new file mode 100644 index 0000000000..e2f6da072a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_system_restrictions.js @@ -0,0 +1,66 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com", "restricted"] }); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.write("response from server"); +}); + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + // The restrictedDomains pref should be set early, because the pref is read + // only once (on first use) by WebExtensionPolicy::IsRestrictedURI. + Services.prefs.setCharPref( + "extensions.webextensions.restrictedDomains", + "restricted" + ); +}); + +async function startDNRExtension() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + browser.test.sendMessage("dnr_registered"); + }, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + return extension; +} + +add_task(async function dnr_ignores_system_requests() { + let extension = await startDNRExtension(); + Assert.equal( + await (await fetch("http://example.com/")).text(), + "response from server", + "DNR should not block requests from system principal" + ); + await extension.unload(); +}); + +add_task(async function dnr_ignores_requests_to_restrictedDomains() { + let extension = await startDNRExtension(); + Assert.equal( + await ExtensionTestUtils.fetch("http://example.com/", "http://restricted/"), + "response from server", + "DNR should not block destination in restrictedDomains" + ); + await extension.unload(); +}); + +add_task(async function dnr_ignores_initiator_from_restrictedDomains() { + let extension = await startDNRExtension(); + Assert.equal( + await ExtensionTestUtils.fetch("http://restricted/", "http://example.com/"), + "response from server", + "DNR should not block requests initiated from a page in restrictedDomains" + ); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js new file mode 100644 index 0000000000..1ce8c4685d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_tabIds.js @@ -0,0 +1,247 @@ +"use strict"; + +// This test verifies that the internals for associating requests with tabId +// are only active when a session rule with a tabId rule exists. +// +// There are tests for the logic of tabId matching in the match_tabIds task in +// toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js +// +// And there are tests that verify matching with real network requests in +// toolkit/components/extensions/test/mochitest/test_ext_dnr_tabIds.html + +const server = createHttpServer({ hosts: ["from", "any", "in", "ex"] }); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); +}); + +let gTabLookupSpy; + +add_setup(async () => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + + // Install a spy on WebRequest.getTabIdForChannelWrapper. + const { WebRequest } = ChromeUtils.import( + "resource://gre/modules/WebRequest.jsm" + ); + const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + gTabLookupSpy = sinon.spy(WebRequest, "getTabIdForChannelWrapper"); + + await ExtensionTestUtils.startAddonManager(); +}); + +function numberOfTabLookupsSinceLastCheck() { + let result = gTabLookupSpy.callCount; + gTabLookupSpy.resetHistory(); + return result; +} + +// This test checks that WebRequest.getTabIdForChannelWrapper is only called +// when there are any registered tabId/excludedTabIds rules. Moreover, it +// verifies that after unloading (reloading) the extension, that the method is +// still not called unnecessarily. +add_task(async function getTabIdForChannelWrapper_only_called_when_needed() { + async function background() { + const RULE_ANY_TAB_ID = { + id: 1, + condition: { requestDomains: ["from"] }, + action: { type: "redirect", redirect: { url: "http://any/" } }, + }; + const RULE_INCLUDE_TAB_ID = { + id: 2, + condition: { requestDomains: ["from"], tabIds: [-1] }, + action: { type: "redirect", redirect: { url: "http://in/" } }, + priority: 2, + }; + const RULE_EXCLUDE_TAB_ID = { + id: 3, + condition: { requestDomains: ["from"], excludedTabIds: [-1] }, + action: { type: "redirect", redirect: { url: "http://ex/" } }, + priority: 2, + }; + async function promiseOneMessage(messageName) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg, result) { + if (messageName === msg) { + browser.test.onMessage.removeListener(listener); + resolve(result); + } + }); + }); + } + async function numberOfTabLookupsSinceLastCheck() { + let promise = promiseOneMessage("tabLookups"); + browser.test.sendMessage("getTabLookups"); + return promise; + } + async function testFetchUrl(url, expectedUrl, expectedCount, description) { + let res = await fetch(url); + browser.test.assertEq(expectedUrl, res.url, `Final URL for ${url}`); + browser.test.assertEq( + expectedCount, + await numberOfTabLookupsSinceLastCheck(), + `Expected number of tab lookups - ${url} - ${description}` + ); + } + + const startupCountPromise = promiseOneMessage("extensionStartupCount"); + browser.test.sendMessage("extensionStarted"); + const startupCount = await startupCountPromise; + if (startupCount !== 0) { + browser.test.assertEq(1, startupCount, "Extension restarted once"); + + // Note: declarativeNetRequest.updateSessionRules is intentionally not + // called here, because we want to verify that upon unloading the + // extension, that the tabId lookup logic was properly cleaned up, + // i.e. that NetworkIntegration.maybeUpdateTabIdChecker() was called. + + await testFetchUrl( + "http://from/?after-restart-supposedly-no-include-tab", + "http://from/?after-restart-supposedly-no-include-tab", + 0, + "No lookup because session rules should have disappeared at reload" + ); + + browser.test.assertDeepEq( + [], + await browser.declarativeNetRequest.getSessionRules(), + "The session rules have indeed been cleared upon reload." + ); + + browser.test.sendMessage("test_completed_after_reload"); + return; + } + + browser.test.assertEq( + 0, + await numberOfTabLookupsSinceLastCheck(), + "Initially, no tab lookups" + ); + + await testFetchUrl( + "http://from/?no_dnr_rules", + "http://from/?no_dnr_rules", + 0, + "No tab lookups without any registered DNR rules" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [RULE_ANY_TAB_ID], + }); + // Active rules now: RULE_ANY_TAB_ID + + await testFetchUrl( + "http://from/?only_dnr_rule_matches_any_tab", + "http://any/", + 0, + "No tab lookups when only rule has no tabIds/excludedTabIds conditions" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [RULE_EXCLUDE_TAB_ID], + }); + // Active rules now: RULE_ANY_TAB_ID, RULE_EXCLUDE_TAB_ID + + await testFetchUrl( + "http://from/?dnr_rule_matches_any,dnr_rule_excludes_-1", + // should be "any" instead of "ex" because excludedTabIds: [-1] should + // exclude the background. + "http://any/", + 2, // initial request + redirect request. + "Expected tabId lookup when a tabId rule is registered" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [RULE_ANY_TAB_ID.id], + }); + // Active rules now: RULE_EXCLUDE_TAB_ID + + await testFetchUrl( + "http://from/?only_dnr_rule_excludes_-1", + // Not redirected to "ex" because excludedTabIds: [-1] does not match the + // background that has tabId -1. + "http://from/?only_dnr_rule_excludes_-1", + 1, + "Expected lookup after unregistering unrelated rule, keeping tabId rule" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [RULE_INCLUDE_TAB_ID], + }); + // Active rules now: RULE_EXCLUDE_TAB_ID, RULE_INCLUDE_TAB_ID + await testFetchUrl( + "http://from/?two_dnr_rule_include_and_exclude_-1", + "http://in/", + 2, // initial request + redirect request. + "Expecting lookup because of 2 DNR rules with tabId and excludedTabIds" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [RULE_EXCLUDE_TAB_ID.id], + }); + // Active rules now: RULE_INCLUDE_TAB_ID + + await testFetchUrl( + "http://from/?only_dnr_rule_includes_-1", + "http://in/", + 2, // initial request + redirect request. + "Expecting lookup because of remaining tabId DNR rule" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + removeRuleIds: [RULE_INCLUDE_TAB_ID.id], + }); + // Active rules now: none + + await testFetchUrl( + "http://from/?no_rules_again", + "http://from/?no_rules_again", + 0, + "Expected no lookups after unregistering the last remaining rule" + ); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [RULE_INCLUDE_TAB_ID], + }); + // Active rules now: RULE_INCLUDE_TAB_ID + + await testFetchUrl( + "http://from/?again_with-include-1", + "http://in/", + 2, // initial request + redirect request. + "Expecting lookup again because of include rule" + ); + + // Ending test with remaining rule: RULE_INCLUDE_TAB_ID + // Reload extension. + browser.test.sendMessage("reload_extension"); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + useAddonManager: "temporary", // for reload and granted_host_permissions. + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + host_permissions: ["*://from/*"], + granted_host_permissions: true, + permissions: ["declarativeNetRequest"], + }, + }); + extension.onMessage("getTabLookups", () => { + extension.sendMessage("tabLookups", numberOfTabLookupsSinceLastCheck()); + }); + let startupCount = 0; + extension.onMessage("extensionStarted", () => { + extension.sendMessage("extensionStartupCount", startupCount++); + }); + await extension.startup(); + await extension.awaitMessage("reload_extension"); + await extension.addon.reload(); + await extension.awaitMessage("test_completed_after_reload"); + Assert.equal( + 0, + numberOfTabLookupsSinceLastCheck(), + "No new tab lookups since completion of extension tests" + ); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js new file mode 100644 index 0000000000..cdd42444d3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_testMatchOutcome.js @@ -0,0 +1,1085 @@ +"use strict"; + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); + + // Don't turn warnings in errors, to make sure that the parameter validation + // tests verify real-world behavior, instead of the stricter test-only mode. + ExtensionTestUtils.failOnSchemaWarnings(false); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + function makeDummyAction(type) { + switch (type) { + case "redirect": + return { type, redirect: { url: "https://example.com/dummy" } }; + case "modifyHeaders": + return { + type, + responseHeaders: [{ operation: "append", header: "x", value: "y" }], + }; + default: + return { type }; + } + } + function makeDummyRequest() { + // A value that matches the condition from makeDummyRule(). + return { url: "https://example.com/some-dummy-url", type: "main_frame" }; + } + function makeDummyRule(id, actionType) { + return { + id, + // condition matches makeDummyRequest(). + condition: { resourceTypes: ["main_frame"] }, + action: makeDummyAction(actionType), + }; + } + async function testMatchesRequest(request, ruleIds, description) { + browser.test.assertDeepEq( + ruleIds, + (await dnr.testMatchOutcome(request)).matchedRules.map(mr => mr.ruleId), + description + ); + } + async function testCanUseAction(type, canUse) { + await dnr.updateSessionRules({ addRules: [makeDummyRule(1, type)] }); + await testMatchesRequest( + makeDummyRequest(), + canUse ? [1] : [], + `${type} - should${canUse ? "" : " not"} match` + ); + await dnr.updateSessionRules({ removeRuleIds: [1] }); + } + Object.assign(dnrTestUtils, { + makeDummyAction, + makeDummyRequest, + makeDummyRule, + testMatchesRequest, + testCanUseAction, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ + background, + manifest, + unloadTestAtEnd = true, +}) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + }); + await extension.startup(); + await extension.awaitFinish(); + if (unloadTestAtEnd) { + await extension.unload(); + } + return extension; +} + +add_task(async function validate_required_params() { + await runAsDNRExtension({ + background: async () => { + const testMatchOutcome = browser.declarativeNetRequest.testMatchOutcome; + + browser.test.assertThrows( + () => testMatchOutcome({ type: "image" }), + /Type error for parameter request \(Property "url" is required\)/, + "url is required" + ); + browser.test.assertThrows( + () => testMatchOutcome({ url: "https://example.com/" }), + /Type error for parameter request \(Property "type" is required\)/, + "resource type is required" + ); + + browser.test.assertDeepEq( + { matchedRules: [] }, + await testMatchOutcome({ url: "https://example.com/", type: "image" }), + "testMatchOutcome with url and type succeeds" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function resource_type_validation() { + await runAsDNRExtension({ + background: async () => { + const testMatchOutcome = browser.declarativeNetRequest.testMatchOutcome; + + const url = "https://example.com/some-dummy-url"; + + browser.test.assertThrows( + () => testMatchOutcome({ url, type: "MAIN_FRAME" }), + /Error processing type: Invalid enumeration value "MAIN_FRAME"/, + "testMatchOutcome should expects a lowercase type" + ); + + // Check that at least one ResourceType exists. + browser.test.assertEq( + "main_frame", + browser.declarativeNetRequest.ResourceType.MAIN_FRAME, + "ResourceType.MAIN_FRAME exists" + ); + + for (let type of Object.values( + browser.declarativeNetRequest.ResourceType + )) { + browser.test.assertDeepEq( + { matchedRules: [] }, + await testMatchOutcome({ url, type }), + `testMatchOutcome for type=${type} is allowed` + ); + } + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function rule_priority_and_action_type_precedence() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyRule, makeDummyRequest } = dnrTestUtils; + + await dnr.updateSessionRules({ + addRules: [ + makeDummyRule(1, "allow"), + makeDummyRule(2, "allowAllRequests"), + makeDummyRule(3, "block"), + makeDummyRule(4, "upgradeScheme"), + makeDummyRule(5, "redirect"), + makeDummyRule(6, "modifyHeaders"), + { ...makeDummyRule(7, "modifyHeaders"), priority: 2 }, + { ...makeDummyRule(8, "allow"), priority: 2 }, + { ...makeDummyRule(9, "block"), priority: 2 }, + // Repeat rules so that we can verify that the outcome is due to the + // rule action, instead of the rule ID / input order. + makeDummyRule(11, "allow"), + makeDummyRule(12, "allowAllRequests"), + makeDummyRule(13, "block"), + makeDummyRule(14, "upgradeScheme"), + makeDummyRule(15, "redirect"), + makeDummyRule(16, "modifyHeaders"), + { ...makeDummyRule(17, "modifyHeaders"), priority: 2 }, + ], + }); + async function testAndRemove(ruleId, expectedRuleIds, description) { + browser.test.assertDeepEq( + expectedRuleIds.map(ruleId => ({ ruleId, rulesetId: "_session" })), + (await dnr.testMatchOutcome(makeDummyRequest())).matchedRules, + description + ); + await dnr.updateSessionRules({ removeRuleIds: [ruleId] }); + } + + await testAndRemove(8, [8], "highest-prio allow wins"); + await testAndRemove(9, [9], "highest-prio block wins"); + // after this point, we only have same-prio rules and two higher-prio + // modifyHeaders rules (7 & 17). + + await testAndRemove( + 1, + [1, 7, 17], + "1st allow ignores other rules, except for higher-prio modifyHeaders" + ); + await testAndRemove( + 11, + [11, 7, 17], + "2nd allow ignores other rules, except for higher-prio modifyHeaders" + ); + + await testAndRemove( + 2, + [2, 7, 17], + "1st allowAllRequests ignores other rules, except for higher-prio modifyHeaders" + ); + await testAndRemove( + 12, + [12, 7, 17], + "2nd allowAllRequests ignores other rules, except for higher-prio modifyHeaders" + ); + + await testAndRemove(3, [3], "1st block > all other actions"); + await testAndRemove(13, [13], "2nd block > all other actions"); + + await testAndRemove(4, [4], "1st upgradeScheme > redirect"); + await testAndRemove(14, [14], "2nd upgradeScheme > redirect"); + + await testAndRemove(5, [5], "1st redirect > modifyHeaders"); + await testAndRemove(15, [15], "2nd redirect > modifyHeaders"); + + await testAndRemove( + 6, + [7, 17, 6, 16], + "All modifyHeaders match if there is no other action" + ); + + // Verify that a new rule takes precedence again. + await dnr.updateSessionRules({ + addRules: [makeDummyRule(11, "allow")], + }); + await testAndRemove( + 11, + [11, 7, 17], + "After adding an allow rule, only higher-prio modifyHeaders are shown" + ); + + browser.test.assertDeepEq( + [7, 16, 17], + (await dnr.getSessionRules()).map(r => r.id), + "Remaining rules at end of test" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function declarativeNetRequest_and_host_permissions() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testCanUseAction } = dnrTestUtils; + + // Unlocked by declarativeNetRequest permission: + await testCanUseAction("allow", true); + await testCanUseAction("allowAllRequests", true); + await testCanUseAction("block", true); + await testCanUseAction("upgradeScheme", true); + // Unlocked by host permissions: + await testCanUseAction("redirect", true); + await testCanUseAction("modifyHeaders", true); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function declarativeNetRequest_permission_only() { + await runAsDNRExtension({ + manifest: { + host_permissions: [], + }, + background: async dnrTestUtils => { + const { testCanUseAction } = dnrTestUtils; + + // Unlocked by declarativeNetRequest permission: + await testCanUseAction("allow", true); + await testCanUseAction("allowAllRequests", true); + await testCanUseAction("block", true); + await testCanUseAction("upgradeScheme", true); + // These require host permissions, which we don't have: + await testCanUseAction("redirect", false); + await testCanUseAction("modifyHeaders", false); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function declarativeNetRequestWithHostAccess_only() { + await runAsDNRExtension({ + manifest: { + permissions: [ + "declarativeNetRequestWithHostAccess", + "declarativeNetRequestFeedback", + ], + host_permissions: [], + }, + background: async dnrTestUtils => { + const { testCanUseAction } = dnrTestUtils; + + // declarativeNetRequestWithHostAccess requires host permissions, + // which we don't have. So none of the rules should match: + await testCanUseAction("allow", false); + await testCanUseAction("allowAllRequests", false); + await testCanUseAction("block", false); + await testCanUseAction("upgradeScheme", false); + await testCanUseAction("redirect", false); + await testCanUseAction("modifyHeaders", false); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function declarativeNetRequestWithHostAccess_only() { + await runAsDNRExtension({ + manifest: { + permissions: [ + "declarativeNetRequestWithHostAccess", + "declarativeNetRequestFeedback", + ], + // Origin used by makeDummyRequest() & makeDummyRule(): + host_permissions: ["https://example.com/"], + }, + background: async dnrTestUtils => { + const { testCanUseAction } = dnrTestUtils; + + // declarativeNetRequestWithHostAccess + host permissions allows all: + await testCanUseAction("allow", true); + await testCanUseAction("allowAllRequests", true); + await testCanUseAction("block", true); + await testCanUseAction("upgradeScheme", true); + await testCanUseAction("redirect", true); + await testCanUseAction("modifyHeaders", true); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: resourceTypes, excludedResourceTypes +// Tests: requestMethods, excludedRequestMethods +add_task(async function match_condition_types_and_methods() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + resourceTypes: ["xmlhttprequest"], + requestMethods: ["put"], + }, + action, + }, + { + id: 2, + condition: { + excludedResourceTypes: ["sub_frame"], + excludedRequestMethods: ["post"], + }, + action, + }, + { + id: 3, + condition: { + // resourceTypes not specified should imply all-minus-main_frame. + requestMethods: ["get", "post"], + }, + action, + }, + { + id: 4, + condition: { + resourceTypes: ["main_frame", "xmlhttprequest"], + excludedRequestMethods: ["get"], + }, + action, + }, + ], + }); + + const url = "https://example.com/some-dummy-url"; + await testMatchesRequest( + { url, type: "main_frame" }, + [2], + "main_frame + GET" + ); + + await testMatchesRequest( + { url, type: "xmlhttprequest" }, + [2, 3], + "xmlhttprequest + GET" + ); + + await testMatchesRequest( + { url, type: "xmlhttprequest", method: "put" }, + [1, 2, 4], + "xmlhttprequest + PUT" + ); + + await testMatchesRequest( + { url, type: "sub_frame", method: "post" }, + [3], + "sub_frame + POST" + ); + + await testMatchesRequest( + { url, type: "sub_frame", method: "post" }, + [3], + "sub_frame + POST" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: requestDomains, excludedRequestDomains +add_task(async function match_request_domains() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + requestDomains: ["a.com", "www.b.com"], + }, + action, + }, + { + id: 2, + condition: { + excludedRequestDomains: ["a.com", "www.b.com", "127.0.0.1"], + }, + action, + }, + { + id: 3, + condition: { + requestDomains: ["one.net"], + excludedRequestDomains: ["sub.one.net"], + }, + action, + }, + { + id: 4, + condition: { + // This can never match. + requestDomains: ["sub.one.net"], + excludedRequestDomains: ["one.net"], + }, + action, + }, + { + id: 5, + condition: { + requestDomains: ["127.0.0.1", "[::1]"], + }, + action, + }, + { + id: 6, + condition: { + requestDomains: [ + "~b.com", // "~" should not be interpreted as pattern negation. + ], + }, + action, + }, + { + id: 7, + condition: { + // A canonical domain does not start with a ".". Domains filters + // starting with a "." are therefore not matching anything. + requestDomains: [".a.com"], + }, + action, + }, + ], + }); + + const type = "sub_frame"; + // Tests related to a.com: + await testMatchesRequest( + { url: "https://a.com:1234/path", type }, + [1], + "a.com: url's domain is equal to a.com" + ); + await testMatchesRequest( + { url: "http://sub.a.com/", type }, + [1], + "sub.a.com: url is subdomain of a.com" + ); + await testMatchesRequest( + { url: "http://nota.com/a.com?a.com#a.com", type }, + [2], + "nota.com: url's domain does not match a.com" + ); + await testMatchesRequest( + { url: "http://a.com.not/a.com?a.com#a.com", type }, + [2], + "a.com.not: url's domain does not match a.com" + ); + await testMatchesRequest( + { url: "http://a.com./a.com?a.com#a.com", type }, + [2], + "a.com.: url's domain (ending with dot) does not match a.com" + ); + + // Tests related to www.b.com: + await testMatchesRequest( + { url: "http://www.b.com/", type }, + [1], + "www.b.com: url's domain is equal to www.b.com" + ); + await testMatchesRequest( + { url: "http://sub.www.b.com", type }, + [1], + "sub.www.b.com: url's domain is a subdomain of www.b.com" + ); + await testMatchesRequest( + { url: "http://b.com/", type }, + [2], + "b.com: url's domain is a superdomain, NOT a subdomain of www.b.com" + ); + + // Tests related to sub.one.net / one.net + await testMatchesRequest( + { url: "http://one.net/", type }, + [2, 3], + "one.net: url's domain matches one.net, but not sub.one.net" + ); + await testMatchesRequest( + { url: "http://sub.one.net/", type }, + [2], // Rule 4 was a candidate, but excluded anyway. + "sub.one.net: url's domain matches sub.one.net, but excluded by one.net" + ); + + // Tests related to IP addresses + await testMatchesRequest( + { url: "http://127.0.0.1:8080/", type }, + [5], + "127.0.0.1: IP address is exact match for 127.0.0.1" + ); + await testMatchesRequest( + { url: "http://8.8.8.8/", type }, + [2], + "8.8.8.8: not matched by any of the domains" + ); + await testMatchesRequest( + { url: "http://9.127.0.0.1/", type }, + [5], + "9.127.0.0.1: while not a valid IP, it looks like a subdomain" + ); + await testMatchesRequest( + { url: "http://[::1]/", type }, + [2, 5], + "[::1]: IPv6 matches with bracket" + ); + + // For completeness, verify that the non-resolving domain "~b.com" + // matches the input, so that we know that "~" was not given special + // treatment. In filter list syntax, "~" before the domain negates the + // meaning, but that should not be supported in DNR. + await testMatchesRequest( + { url: "http://~b.com/", type }, + [2, 6], + "~b.com: Although a non-resolving domain, it matches the pattern" + ); + + // match_initiator_domains has more tests; here we just confirm that + // requestDomains rules don't match initiator. + await testMatchesRequest( + { url: "http://url.does.not.match/", type, initiator: "http://a.com/" }, + [2], + "requestDomains should not match initiator URL" + ); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function match_request_domains_punycode() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + // Note that the non-punycode domains are rejected by schema validation, + // and checked by test validate_domains in test_ext_dnr_session_rules.js. + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + // straß.de + requestDomains: ["xn--stra-yna.de"], + }, + action, + }, + { + id: 2, + condition: { + // IDNA2003 converted ß to ss. But IDNA2008 requires punycode. + requestDomains: ["strass.de", "stras.de"], + }, + action, + }, + ], + }); + + const type = "sub_frame"; + + await testMatchesRequest( + { url: "https://straß.de/", type }, + [1], + "straß.de matches" + ); + await testMatchesRequest( + { url: "https://xn--stra-yna.de/", type }, + [1], + "xn--stra-yna.de matches" + ); + await testMatchesRequest( + { url: "https://strass.de/", type }, + [2], + "strass.de does not match the punycode pattern of straß" + ); + await testMatchesRequest( + { url: "https://stras.de/", type }, + [2], + "stras.de does not match the punycode pattern of straß" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: initiatorDomains, excludedInitiatorDomains +add_task(async function match_initiator_domains() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + // The validation of initiatorDomains and requestDomains are shared. + // The match_request_domains and match_request_domains_punycode tests + // already verify semantics; this test just tests that the conditional + // logic works as expected, plus coverage for initiator being void. + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + initiatorDomains: ["a.com"], + }, + action, + }, + { + id: 2, + condition: { + excludedInitiatorDomains: ["a.com"], + }, + action, + }, + { + id: 3, + condition: { + initiatorDomains: ["c.com"], + excludedInitiatorDomains: ["c.com"], + }, + action, + }, + { + id: 4, // To verify that it does not match a void initiator. + condition: { + initiatorDomains: ["null"], + }, + action, + }, + { + id: 5, + condition: { + excludedInitiatorDomains: ["null", "undefined"], + }, + action, + }, + { + id: 6, // To verify that it does not match a void initiator. + condition: { + initiatorDomains: ["undefined"], + }, + action, + }, + ], + }); + + const url = "https://do.not.look.here/look_at_initator_instead"; + const type = "image"; + await testMatchesRequest( + { url, type, initiator: "http://a.com/" }, + [1, 5], + "initiatorDomains matches" + ); + await testMatchesRequest( + { url, type, initiator: "http://b.com/" }, + [2, 5], + "excludedInitiatorDomains does not match, so request matched" + ); + await testMatchesRequest( + { url, type, initiator: "http://c.com/" }, + [2, 5], // 3 is not here, despite containing "c.com". + "excludedInitiatorDomains takes precedence over initiatorDomains" + ); + // When initiator is not specified, rules with initiatorDomains should not + // match, and rules with excludedInitiatorDomains may match. + await testMatchesRequest( + { url, type }, + [2, 5], + "request without initiator matches every excludedInitiatorDomains" + ); + // http://null is unlikely to exist in practice. Regardless, verify that + // it won't match a void initiators. + await testMatchesRequest( + { url, type, initiator: "http://null/" }, + [2, 4], + "http://null is matched by the 'null' domain" + ); + await testMatchesRequest( + { url, type, initiator: "http://undefined/" }, + [2, 6], + "http://null is matched by the 'undefined' domain" + ); + await testMatchesRequest( + { url: "http://a.com/", type }, + [2, 5], + "initiatorDomains should not match the request URL (initiator=null)" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: urlFilter. For more comprehensive tests, see +// toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js +add_task(async function match_urlFilter() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + // Some patterns that match literally everything: + { id: 1, condition: { urlFilter: "*" }, action }, + { id: 2, condition: { urlFilter: "^" }, action }, + { id: 3, condition: { urlFilter: "|" }, action }, + // Patterns that match the test URLs + { id: 4, condition: { urlFilter: "https://example.com" }, action }, + { + // urlFilter matches, requestDomains matches. + id: 5, + condition: { urlFilter: "*", requestDomains: ["example.com"] }, + action, + }, + { + // urlFilter matches, requestDomains does not match. + id: 6, + condition: { urlFilter: "*", requestDomains: ["notexample.com"] }, + action, + }, + { + // urlFilter does not match, requestDomains matches. + id: 7, + condition: { urlFilter: "notm", requestDomains: ["example.com"] }, + action, + }, + ], + }); + + await testMatchesRequest( + { url: "https://example.com/file.txt", type: "font" }, + [1, 2, 3, 4, 5], + "urlFilter should match when needed, and correctly with requestDomains" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Tests: tabIds, excludedTabIds +add_task(async function match_tabIds() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction, testMatchesRequest } = dnrTestUtils; + + // "modifyHeaders" is the only action that allows multiple rule matches. + const action = makeDummyAction("modifyHeaders"); + + await dnr.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + excludedTabIds: [-1, Number.MAX_SAFE_INTEGER], + }, + action, + }, + { + id: 2, + condition: { + tabIds: [1, Number.MAX_SAFE_INTEGER], + }, + action, + }, + { + id: 3, + condition: { + tabIds: [-1], + }, + action, + }, + ], + }); + + const url = "https://example.com/some-dummy-url"; + const type = "font"; + await testMatchesRequest({ url, type }, [3], "tabId defaults to -1"); + await testMatchesRequest({ url, type, tabId: -1 }, [3], "tabId -1"); + await testMatchesRequest({ url, type, tabId: 1 }, [1, 2], "tabId 1"); + await testMatchesRequest( + { + url, + type, + tabId: Number.MAX_SAFE_INTEGER, + }, + [2], + `tabId high number (MAX_SAFE_INTEGER=${Number.MAX_SAFE_INTEGER})` + ); + + // tabId -2 is invalid and not encountered in practice, but technically + // it matches the first rule. + await testMatchesRequest({ url, type, tabId: -2 }, [1], "bad tabId -2"); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function action_precedence_between_extensions() { + // This test is structured as follows: + // - otherExtension registers rules for several numeric conditions (tabId). + // - otherExtensionNonBlockAndModifyHeaders adds allowAllRequests and + // modifyHeaders to all requests. + // - otherExtensionModifyHeaders adds modifyHeaders rules to all requests. + // - the main test extension also registers rules, and then simulates requests + // with testMatchOutcome for each tabId, and checks the result. + + let otherExtension = await runAsDNRExtension({ + manifest: { browser_specific_settings: { gecko: { id: "other@ext" } } }, + background: async dnrTestUtils => { + const { makeDummyAction } = dnrTestUtils; + + // Dummy condition for testing requests in this test. + const c = tabId => ({ resourceTypes: ["main_frame"], tabIds: [tabId] }); + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { id: 11, condition: c(1), action: makeDummyAction("allow") }, + { id: 12, condition: c(2), action: makeDummyAction("block") }, + { id: 13, condition: c(3), action: makeDummyAction("redirect") }, + { id: 14, condition: c(4), action: makeDummyAction("upgradeScheme") }, + { + id: 15, + condition: c(5), + action: makeDummyAction("allowAllRequests"), + }, + { + id: 16, + condition: c(6), + action: makeDummyAction("allowAllRequests"), + }, + ], + }); + // Notify to continue. We don't exit yet due to unloadTestAtEnd:false + browser.test.notifyPass(); + }, + unloadTestAtEnd: false, + }); + + let otherExtensionNonBlockAndModifyHeaders = await runAsDNRExtension({ + manifest: { browser_specific_settings: { gecko: { id: "other@ext2" } } }, + background: async dnrTestUtils => { + const { makeDummyAction } = dnrTestUtils; + + // Matches all requests from this test. + const condition = { resourceTypes: ["main_frame"] }; + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1000, + condition, + action: makeDummyAction("modifyHeaders"), + // Same-or-lower priority "modifyHeaders" actions are ignored when + // an "allowAllRequests" action exists within the same extension. + // Since we have such a rule (ID 1001), this modifyHeaders rule must + // have "priority: 2" to avoid being ignored. + priority: 2, + }, + { id: 1001, condition, action: makeDummyAction("allowAllRequests") }, + { + id: 1002, + condition, + action: makeDummyAction("modifyHeaders"), + priority: 2, // necessary as explained above at rule ID 1000. + }, + // should never appear because the first allowAllRequests rule should + // take precedence: + { id: 1003, condition, action: makeDummyAction("allowAllRequests") }, + ], + }); + + // Notify to continue. We don't exit yet due to unloadTestAtEnd:false + browser.test.notifyPass(); + }, + unloadTestAtEnd: false, + }); + + // |otherExtensionModifyHeaders| and |otherExtensionNonBlockAndModifyHeaders| + // both have "modifyHeaders" rules. The documented order of rules is for + // the most recently installed extension to take precedence when applying + // modifyHeaders actions. The "priority" key is extension-specific, so even + // though |otherExtensionNonBlockAndModifyHeaders| defines "priority: 2" for + // modifyHeaders action (ID 1001), the modifyHeaders below (ID 1337) takes + // precedence because the extension was installed later. + let otherExtensionModifyHeaders = await runAsDNRExtension({ + manifest: { browser_specific_settings: { gecko: { id: "other@ext3" } } }, + background: async dnrTestUtils => { + const { makeDummyAction } = dnrTestUtils; + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1337, + // Matches all requests from this test. + condition: { resourceTypes: ["main_frame"] }, + action: makeDummyAction("modifyHeaders"), + // Note: no "priority" key set, so defaults to 1. + }, + ], + }); + // Notify to continue. We don't exit yet due to unloadTestAtEnd:false + browser.test.notifyPass(); + }, + unloadTestAtEnd: false, + }); + + await runAsDNRExtension({ + background: async dnrTestUtils => { + const dnr = browser.declarativeNetRequest; + const { makeDummyAction } = dnrTestUtils; + + // Dummy condition for testing requests in this test. + const c = tabId => ({ resourceTypes: ["main_frame"], tabIds: [tabId] }); + + await dnr.updateSessionRules({ + addRules: [ + { id: 91, condition: c(1), action: makeDummyAction("block") }, + { id: 92, condition: c(2), action: makeDummyAction("allow") }, + { id: 93, condition: c(3), action: makeDummyAction("block") }, + { id: 94, condition: c(4), action: makeDummyAction("block") }, + { id: 95, condition: c(5), action: makeDummyAction("allow") }, + { + id: 96, + condition: c(6), + action: makeDummyAction("allowAllRequests"), + }, + ], + }); + + const url = "https://example.com/dummy-url"; + const type = "main_frame"; + const options = { includeOtherExtensions: true }; + browser.test.assertDeepEq( + [{ ruleId: 91, rulesetId: "_session" }], + (await dnr.testMatchOutcome({ url, type, tabId: 1 }, options)) + .matchedRules, + "block takes precedence over allow (from other extension)" + ); + + browser.test.assertDeepEq( + [{ ruleId: 12, rulesetId: "_session", extensionId: "other@ext" }], + (await dnr.testMatchOutcome({ url, type, tabId: 2 }, options)) + .matchedRules, + "block (from other extension) takes precedence over allow" + ); + browser.test.assertDeepEq( + [{ ruleId: 93, rulesetId: "_session" }], + (await dnr.testMatchOutcome({ url, type, tabId: 3 }, options)) + .matchedRules, + "block takes precedence over redirect (from other extension)" + ); + browser.test.assertDeepEq( + [{ ruleId: 94, rulesetId: "_session" }], + (await dnr.testMatchOutcome({ url, type, tabId: 4 }, options)) + .matchedRules, + "block takes precedence over upgradeScheme (from other extension)" + ); + browser.test.assertDeepEq( + [ + // allow: + { ruleId: 95, rulesetId: "_session" }, + // allowAllRequests (newest install first): + { ruleId: 1001, rulesetId: "_session", extensionId: "other@ext2" }, + { ruleId: 15, rulesetId: "_session", extensionId: "other@ext" }, + // modifyHeaders (see comment at otherExtensionModifyHeaders): + { ruleId: 1337, rulesetId: "_session", extensionId: "other@ext3" }, + { ruleId: 1000, rulesetId: "_session", extensionId: "other@ext2" }, + { ruleId: 1002, rulesetId: "_session", extensionId: "other@ext2" }, + ], + (await dnr.testMatchOutcome({ url, type, tabId: 5 }, options)) + .matchedRules, + "When allow matches, allowAllRequests from other extension matches too" + ); + browser.test.assertDeepEq( + [ + // allowAllRequests (newest install first): + { ruleId: 96, rulesetId: "_session" }, + { ruleId: 1001, rulesetId: "_session", extensionId: "other@ext2" }, + { ruleId: 16, rulesetId: "_session", extensionId: "other@ext" }, + // modifyHeaders (see comment at otherExtensionModifyHeaders): + { ruleId: 1337, rulesetId: "_session", extensionId: "other@ext3" }, + { ruleId: 1000, rulesetId: "_session", extensionId: "other@ext2" }, + { ruleId: 1002, rulesetId: "_session", extensionId: "other@ext2" }, + ], + (await dnr.testMatchOutcome({ url, type, tabId: 6 }, options)) + .matchedRules, + "allowAllRequests from all other extensions are matched" + ); + + browser.test.notifyPass(); + }, + }); + + await otherExtension.unload(); + await otherExtensionNonBlockAndModifyHeaders.unload(); + await otherExtensionModifyHeaders.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js new file mode 100644 index 0000000000..9c6bd8b459 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_urlFilter.js @@ -0,0 +1,1101 @@ +"use strict"; + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.feedback", true); +}); + +// This function is serialized and called in the context of the test extension's +// background page. dnrTestUtils is passed to the background function. +function makeDnrTestUtils() { + const dnrTestUtils = {}; + const dnr = browser.declarativeNetRequest; + + const DUMMY_ACTION = { + // "modifyHeaders" is the only action that allows multiple rule matches. + type: "modifyHeaders", + responseHeaders: [{ operation: "append", header: "x", value: "y" }], + }; + async function testMatchesRequest(request, ruleIds, description) { + browser.test.assertDeepEq( + ruleIds, + (await dnr.testMatchOutcome(request)).matchedRules.map(mr => mr.ruleId), + description + ); + } + async function testMatchesUrlFilter({ + urlFilter, + isUrlFilterCaseSensitive = false, + urls = [], + urlsNonMatching = [], + }) { + // Sanity check: verify that there are no unexpected escaped characters, + // because that can surprise. + function sanityCheckUrl(url) { + const normalizedUrl = new URL(url).href; + if (normalizedUrl.split("%").length !== url.split("*").length) { + // ^ we only check for %-escapes and not exact URL equality because the + // tests imported from Chrome often omit the "/" (path separator). + browser.test.assertEq(normalizedUrl, url, "url should be canonical"); + } + } + + await dnr.updateSessionRules({ + addRules: [ + { + id: 12345, + condition: { urlFilter, isUrlFilterCaseSensitive }, + action: DUMMY_ACTION, + }, + ], + }); + for (let url of urls) { + sanityCheckUrl(url); + const request = { url, type: "other" }; + const description = `urlFilter ${urlFilter} should match: ${url}`; + await testMatchesRequest(request, [12345], description); + } + for (let url of urlsNonMatching) { + sanityCheckUrl(url); + const request = { url, type: "other" }; + const description = `urlFilter ${urlFilter} should not match: ${url}`; + await testMatchesRequest(request, [], description); + } + await dnr.updateSessionRules({ removeRuleIds: [12345] }); + } + Object.assign(dnrTestUtils, { + DUMMY_ACTION, + testMatchesRequest, + testMatchesUrlFilter, + }); + return dnrTestUtils; +} + +async function runAsDNRExtension({ background, manifest }) { + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})((${makeDnrTestUtils})())`, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest", "declarativeNetRequestFeedback"], + // While testing urlFilter itself does not require any host permissions, + // we are asking for host permissions anyway because the "modifyHeaders" + // action requires host permissions, and we use the "modifyHeaders" action + // to ensure that we can detect when multiple rules match. + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifest, + }, + temporarilyInstalled: true, // <-- for granted_host_permissions + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +} + +// This test checks various urlFilters with a possibly ambiguous interpretation. +// In some cases the semantic difference in interpretation can have different +// outcomes; in these cases we have chosen the behavior as observed in Chrome. +add_task(async function ambiguous_urlFilter_patterns() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesUrlFilter } = dnrTestUtils; + + // Left anchor, empty pattern: always matches + // Ambiguous with Right anchor, but same result. + await testMatchesUrlFilter({ + urlFilter: "|", + urls: ["http://a/"], + urlsNonMatching: [], + }); + + // Domain anchor, empty pattern: always matches. + // Ambiguous with Left anchor + Right anchor, the latter would not match + // anything (only an empty string, but URLs cannot be empty). + await testMatchesUrlFilter({ + urlFilter: "||", + urls: ["http://a/"], + urlsNonMatching: [], + }); + + // Domain anchor plus Right separator: never matches. + // Ambiguous with Left anchor + | + Right anchor, that is no match either. + await testMatchesUrlFilter({ + urlFilter: "|||", + urls: [], + urlsNonMatching: ["http://a./|||"], + }); + + // Repeated separator: ^^^^ matches separator chars (=everything except + // alphanumeric, "_", "-", ".", "%"), but when at the end of a string, + // the last "^" can also be interpreted as a right anchor (like ^^^|). + // Ambiguous: while "^" is defined to match the end of URL, it could also + // be interpreted as "^^^^" matching the end of URL 4x, i.e. always. + await testMatchesUrlFilter({ + urlFilter: "^^^^", + urls: [ + // Note: "^" is escaped "%5E" when part of the URL, except after "#". + "http://a/#frag^^^^", // four ^ characters ("^^^^"). + "http://a/#frag^^^", // three ^ characters ("^^^") + end of URL. + "http://a/?&#", // four separator characters ("/?&#"); + "http://a/#^", // three separator characters ("/??") + end of URL. + // ^ Note that "^" is after "#" and therefore not %5E. If "^" were to + // somehow be %-encoded to "%5E", then the end would become "/#%5E" + // and the "/#%" would only be 3 separators followed by alphanum. The + // test matching shows that the canonical representation of "^" after + // a "#" is "^" and can be matched. + ], + urlsNonMatching: [ + "http://a/?", // Just two separator + end of URL, not matching 4x "^". + "http://a/____", // _ is specified to not match ^. + "http://a/----", // - is specified to not match ^. + "http://a/....", // . is specified to not match ^. + ], + }); + // Not ambiguous, but for comparison with "^^^^": all http(s) match. + await testMatchesUrlFilter({ + urlFilter: "^^^", + urls: ["https://a/"], // "://" always matches "^^^". + // Not seen by DNR in practice, but could be passed to testMatchOutcome: + urlsNonMatching: ["file:hello/no/three/consecutive/special/characters"], + }); + + // Separator plus Right anchor: always matches. + // Ambiguous: "^" is defined to match the end of URL once, but a right + // domain anchor already matches that. A potential interpretation is for + // "^" to be required to match a non-alphanumeric (etc.), but in practice + // "^" is allowed to match the end of the URL. Effectively "^|" = "|". + await testMatchesUrlFilter({ + urlFilter: "^|", + urls: [ + "http://a/", // "/" matches "^". + "http://a/a", // "a" does not match "^", but "^" matches the end. + ], + urlsNonMatching: [], + }); + + // Domain anchor plus separator: "^" only matches non-alphanum (etc.) + // Ambiguous: "||" is defined to match a domain anchor. There is no + // domain part after the trailing "." of a FQDN. Still, "." matches. + await testMatchesUrlFilter({ + urlFilter: "||^", + urls: ["http://a./"], // FQDN: "/" after "." matches "^". + urlsNonMatching: ["http://a/", "http://a/||"], + }); + + browser.test.notifyPass(); + }, + }); +}); + +add_task(async function urlFilter_domain_anchor() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesUrlFilter } = dnrTestUtils; + + await testMatchesUrlFilter({ + // Not a domain anchor, but for comparison with "||ps" below: + urlFilter: "ps", + urls: [ + "https://example.com/", // ps in scheme. + "http://ps.example.com/", // ps at start of domain. + "http://sub.ps.example.com/", // ps at superdomain. + "http://ps/", // ps as sole host. + "http://example-ps.com/", // ps in middle of domain. + "http://ps@example.com/", // ps as user without password. + "http://user:ps@example.com/", // ps in password. + "http://ps:pass@example.com/", // ps in user. + "http://example.com/ps", // ps at end. + "http://example.com/#ps", // ps in fragment. + ], + urlsNonMatching: [ + "http://example.com/", // no ps anywhere. + ], + }); + + await testMatchesUrlFilter({ + urlFilter: "||ps", + urls: [ + "http://ps.example.com/", // ps at start of domain. + "http://sub.ps.example.com/", // ps at superdomain. + "http://ps/", // ps as sole host. + ], + urlsNonMatching: [ + "http://example.com/", // no ps anywhere. + "https://example.com/", // ps in scheme. + "http://example-ps.com/", // ps in middle + "http://ps@example.com/", // ps as user without password. + "http://user:ps@example.com/", // ps in password. + "http://ps:pass@example.com/", // ps in user. + "http://example.com/ps", // ps at end. + ], + }); + + await testMatchesUrlFilter({ + urlFilter: "||1", + urls: [ + "http://127.0.0.1/", + "http://2.0.0.1/", + "http://www.1example.com/", + ], + urlsNonMatching: [ + "http://[::1]/", + "http://[1::1]/", + "http://hostwithport:1/", + "http://host/1", + "http://fqdn.:1/", + "http://fqdn./1", + ], + }); + + await testMatchesUrlFilter({ + urlFilter: "||^1", + urls: [ + "http://[1::1]/", // "[1" at start matches "^1". + "http://fqdn.:1/", // ":1" matches "^1" and is after a ".". + "http://fqdn./1", // "/1" matches "^1" and is after a ".". + ], + urlsNonMatching: [ + "http://127.0.0.1/", + "http://2.0.0.1/", + "http://www.1example.com/", + "http://[::1]/", + "http://hostwithport:1/", + "http://host/1", + ], + }); + + browser.test.notifyPass(); + }, + }); +}); + +// Extreme patterns that should not be used in practice, but are not explicitly +// documented to be disallowed. +add_task(async function extreme_urlFilter_patterns() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesRequest, DUMMY_ACTION } = dnrTestUtils; + + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + urlFilter: "*".repeat(1e6), + }, + action: DUMMY_ACTION, + }, + { + id: 2, + condition: { + urlFilter: "^".repeat(1e6), + }, + action: DUMMY_ACTION, + }, + { + id: 3, + condition: { + // Note: 2 chars repeat 5e5 instead of 1e6 because newURI limits + // the length of the URL (to network.standard-url.max-length), so + // we would not be able to verify whether the URL is really that + // long. + urlFilter: "*^".repeat(5e5), + }, + action: DUMMY_ACTION, + }, + { + id: 4, + condition: { + // Note: well beyond the maximum length of a URL. But as "*" can + // match any char (including zero length), this still matches. + urlFilter: "h" + "*".repeat(1e7) + "endofurl", + }, + action: DUMMY_ACTION, + }, + ], + }); + + await testMatchesRequest( + { url: "http://example.com/", type: "other" }, + [1], + "urlFilter with 1M wildcard chars matches any URL" + ); + + await testMatchesRequest( + { url: "http://example.com/" + "x".repeat(1e6), type: "other" }, + [1], + "urlFilter with 1M wildcards matches, other '^' do not match alpha" + ); + + await testMatchesRequest( + { url: "http://example.com/" + "/".repeat(1e6), type: "other" }, + [1, 2, 3], + "urlFilter with 1M wildcards, ^ and *^ all match URL with 1M '/' chars" + ); + + await testMatchesRequest( + { url: "http://example.com/" + "x/".repeat(5e5), type: "other" }, + [1, 3], + "urlFilter with 1M wildcards and *^ match URL with 1M 'x/' chars" + ); + + await testMatchesRequest( + { url: "http://example.com/endofurl", type: "other" }, + [1, 4], + "urlFilter with 1M and 10M wildcards matches URL" + ); + + browser.test.notifyPass(); + }, + }); +}); + +// Imported tests from Chromium from: +// https://chromium.googlesource.com/chromium/src.git/+/refs/tags/110.0.5442.0/components/url_pattern_index/url_pattern_unittest.cc +// kAnchorNone -> "" (anywhere in the string) +// kBoundary -> | (start or end of string) +// kSubdomain -> || (start of (sub)domain) +// kMatchCase -> isUrlFilterCaseSensitive: true +// kDonotMatchCase -> isUrlFilterCaseSensitive: false (this is the default). +// proto::URL_PATTERN_TYPE_WILDCARDED / proto::URL_PATTERN_TYPE_SUBSTRING -> "" +// +// Minus two tests ("", kBoundary, kBoundary) because the resulting pattern is +// "||" and ambiguous with ("", kSubdomain, ""). +add_task(async function test_chrome_parity() { + await runAsDNRExtension({ + background: async dnrTestUtils => { + const { testMatchesUrlFilter } = dnrTestUtils; + const testCases = [ + // {"", proto::URL_PATTERN_TYPE_SUBSTRING} + { + urlFilter: "*", + url: "http://ex.com/", + expectMatch: true, + }, + // // {"", proto::URL_PATTERN_TYPE_WILDCARDED} + // { // Already tested before. + // urlFilter: "*", + // url: "http://ex.com/", + // expectMatch: true, + // }, + // {"", kBoundary, kAnchorNone} + { + urlFilter: "|", + url: "http://ex.com/", + expectMatch: true, + }, + // {"", kSubdomain, kAnchorNone} + { + urlFilter: "||", + url: "http://ex.com/", + expectMatch: true, + }, + // // {"", kSubdomain, kAnchorNone} + // { // Already tested before. + // urlFilter: "||", + // url: "http://ex.com/", + // expectMatch: true, + // }, + // {"^", kSubdomain, kAnchorNone} + { + urlFilter: "||^", + url: "http://ex.com/", + expectMatch: false, + }, + // {".", kSubdomain, kAnchorNone} + { + urlFilter: "||.", + url: "http://ex.com/", + expectMatch: false, + }, + // // {"", kAnchorNone, kBoundary} + // { // Already tested before. + // urlFilter: "|", + // url: "http://ex.com/", + // expectMatch: true, + // }, + // {"^", kAnchorNone, kBoundary} + { + urlFilter: "^|", + url: "http://ex.com/", + expectMatch: true, + }, + // {".", kAnchorNone, kBoundary} + { + urlFilter: ".|", + url: "http://ex.com/", + expectMatch: false, + }, + // // {"", kBoundary, kBoundary} + // { // "||" is ambiguous, cannot mean Left anchor + Right anchor + // urlFilter: "||", + // url: "http://ex.com/", + // expectMatch: false, + // }, + // {"", kSubdomain, kBoundary} + { + urlFilter: "|||", + url: "http://ex.com/", + expectMatch: false, + }, + // {"com/", kSubdomain, kBoundary} + { + urlFilter: "||com/|", + url: "http://ex.com/", + expectMatch: true, + }, + // {"xampl", proto::URL_PATTERN_TYPE_SUBSTRING} + { + urlFilter: "xampl", + url: "http://example.com", + expectMatch: true, + }, + // {"example", proto::URL_PATTERN_TYPE_SUBSTRING} + { + urlFilter: "example", + url: "http://example.com", + expectMatch: true, + }, + // {"/a?a"} + { + urlFilter: "/a?a", + url: "http://ex.com/a?a", + expectMatch: true, + }, + // {"^abc"} + { + urlFilter: "^abc", + url: "http://ex.com/abc?a", + expectMatch: true, + }, + // {"^abc"} + { + urlFilter: "^abc", + url: "http://ex.com/a?abc", + expectMatch: true, + }, + // {"^abc"} + { + urlFilter: "^abc", + url: "http://ex.com/abc?abc", + expectMatch: true, + }, + // {"^abc^abc"} + { + urlFilter: "^abc^abc", + url: "http://ex.com/abc?abc", + expectMatch: true, + }, + // {"^com^abc^abc"} + { + urlFilter: "^com^abc^abc", + url: "http://ex.com/abc?abc", + expectMatch: false, + }, + // {"http://ex", kBoundary, kAnchorNone} + { + urlFilter: "|http://ex", + url: "http://example.com", + expectMatch: true, + }, + // {"http://ex", kAnchorNone, kAnchorNone} + { + urlFilter: "http://ex", + url: "http://example.com", + expectMatch: true, + }, + // {"mple.com/", kAnchorNone, kBoundary} + { + urlFilter: "mple.com/|", + url: "http://example.com", + expectMatch: true, + }, + // {"mple.com/", kAnchorNone, kAnchorNone} + { + urlFilter: "mple.com/", + url: "http://example.com", + expectMatch: true, + }, + // {"mple.com/", kSubdomain, kAnchorNone} + { + urlFilter: "||mple.com/", + url: "http://example.com", + expectMatch: false, + }, + // {"ex.com", kSubdomain, kAnchorNone} + { + urlFilter: "||ex.com", + url: "http://hex.com", + expectMatch: false, + }, + // {"ex.com", kSubdomain, kAnchorNone} + { + urlFilter: "||ex.com", + url: "http://ex.com", + expectMatch: true, + }, + // {"ex.com", kSubdomain, kAnchorNone} + { + urlFilter: "||ex.com", + url: "http://hex.ex.com", + expectMatch: true, + }, + // {"ex.com", kSubdomain, kAnchorNone} + { + urlFilter: "||ex.com", + url: "http://hex.hex.com", + expectMatch: false, + }, + // {"example.com^", kSubdomain, kAnchorNone} + { + urlFilter: "||example.com^", + url: "http://www.example.com", + expectMatch: true, + }, + // {"http://*mpl", kBoundary, kAnchorNone} + { + urlFilter: "|http://*mpl", + url: "http://example.com", + expectMatch: true, + }, + // {"mpl*com/", kAnchorNone, kBoundary} + { + urlFilter: "mpl*com/|", + url: "http://example.com", + expectMatch: true, + }, + // {"example^com"} + { + urlFilter: "example^com", + url: "http://example.com", + expectMatch: false, + }, + // {"example^com"} + { + urlFilter: "example^com", + url: "http://example/com", + expectMatch: true, + }, + // {"example.com^"} + { + urlFilter: "example.com^", + url: "http://example.com:8080", + expectMatch: true, + }, + // {"http*.com/", kBoundary, kBoundary} + { + urlFilter: "|http*.com/|", + url: "http://example.com", + expectMatch: true, + }, + // {"http*.org/", kBoundary, kBoundary} + { + urlFilter: "|http*.org/|", + url: "http://example.com", + expectMatch: false, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path/bbb?k=v&p1=0&p2=1", + expectMatch: false, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path?k=v&p1=0&p2=1", + expectMatch: true, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path?k=v&k=v&p1=0&p2=1", + expectMatch: true, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path?k=v&p1=0&p3=10&p2=1", + expectMatch: true, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path&p1=0&p2=1", + expectMatch: false, + }, + // {"/path?*&p1=*&p2="} + { + urlFilter: "/path?*&p1=*&p2=", + url: "http://ex.com/aaa/path?k=v&p2=0&p1=1", + expectMatch: false, + }, + // {"abc*def*ghijk*xyz"} + { + urlFilter: "abc*def*ghijk*xyz", + url: "http://example.com/abcdeffffghijkmmmxyzzz", + expectMatch: true, + }, + // {"abc*cdef"} + { + urlFilter: "abc*cdef", + url: "http://example.com/abcdef", + expectMatch: false, + }, + // {"^^a^^"} + { + urlFilter: "^^a^^", + url: "http://ex.com/?a=/", + expectMatch: true, + }, + // {"^^a^^"} + { + urlFilter: "^^a^^", + url: "http://ex.com/?a=/&b=0", + expectMatch: true, + }, + // {"^^a^^"} + { + urlFilter: "^^a^^", + url: "http://ex.com/?a=x", + expectMatch: false, + }, + // {"^^a^^"} + { + urlFilter: "^^a^^", + url: "http://ex.com/?a=", + expectMatch: true, + }, + // {"ex.com^path^*k=v^"} + { + urlFilter: "ex.com^path^*k=v^", + url: "http://ex.com/path/?k1=v1&ak=v&kk=vv", + expectMatch: true, + }, + // {"ex.com^path^*k=v^"} + { + urlFilter: "ex.com^path^*k=v^", + url: "http://ex.com/p/path/?k1=v1&ak=v&kk=vv", + expectMatch: false, + }, + // {"a^a&a^a&"} + { + urlFilter: "a^a&a^a&", + url: "http://ex.com/a/a/a/a/?a&a&a&a&a", + expectMatch: true, + }, + // {"abc*def^"} + { + urlFilter: "abc*def^", + url: "http://ex.com/abc/a/ddef/", + expectMatch: true, + }, + // {"https://example.com/"} + { + urlFilter: "https://example.com/", + url: "http://example.com/", + expectMatch: false, + }, + // {"example.com/", kSubdomain, kAnchorNone} + { + urlFilter: "||example.com/", + url: "http://example.com/", + expectMatch: true, + }, + // {"examp", kSubdomain, kAnchorNone} + { + urlFilter: "||examp", + url: "http://example.com/", + expectMatch: true, + }, + // {"xamp", kSubdomain, kAnchorNone} + { + urlFilter: "||xamp", + url: "http://example.com/", + expectMatch: false, + }, + // {"examp", kSubdomain, kAnchorNone} + { + urlFilter: "||examp", + url: "http://test.example.com/", + expectMatch: true, + }, + // {"t.examp", kSubdomain, kAnchorNone} + { + urlFilter: "||t.examp", + url: "http://test.example.com/", + expectMatch: false, + }, + // {"com^", kSubdomain, kAnchorNone} + { + urlFilter: "||com^", + url: "http://test.example.com/", + expectMatch: true, + }, + // {"com^x", kSubdomain, kBoundary} + { + urlFilter: "||com^x|", + url: "http://a.com/x", + expectMatch: true, + }, + // {"x.com", kSubdomain, kAnchorNone} + { + urlFilter: "||x.com", + url: "http://ex.com/?url=x.com", + expectMatch: false, + }, + // {"ex.com/", kSubdomain, kBoundary} + { + urlFilter: "||ex.com/|", + url: "http://ex.com/", + expectMatch: true, + }, + // {"ex.com^", kSubdomain, kBoundary} + { + urlFilter: "||ex.com^|", + url: "http://ex.com/", + expectMatch: true, + }, + // {"ex.co", kSubdomain, kBoundary} + { + urlFilter: "||ex.co|", + url: "http://ex.com/", + expectMatch: false, + }, + // {"ex.com", kSubdomain, kBoundary} + { + urlFilter: "||ex.com|", + url: "http://rex.com.ex.com/", + expectMatch: false, + }, + // {"ex.com/", kSubdomain, kBoundary} + { + urlFilter: "||ex.com/|", + url: "http://rex.com.ex.com/", + expectMatch: true, + }, + // {"http", kSubdomain, kBoundary} + { + urlFilter: "||http|", + url: "http://http.com/", + expectMatch: false, + }, + // {"http", kSubdomain, kAnchorNone} + { + urlFilter: "||http", + url: "http://http.com/", + expectMatch: true, + }, + // {"/example.com", kSubdomain, kBoundary} + { + urlFilter: "||/example.com|", + url: "http://example.com/", + expectMatch: false, + }, + // {"/example.com/", kSubdomain, kBoundary} + { + urlFilter: "||/example.com/|", + url: "http://example.com/", + expectMatch: false, + }, + // {".", kSubdomain, kAnchorNone} + { + urlFilter: "||.", + url: "http://a..com/", + expectMatch: true, + }, + // {"^", kSubdomain, kAnchorNone} + { + urlFilter: "||^", + url: "http://a..com/", + expectMatch: false, + }, + // {".", kSubdomain, kAnchorNone} + { + urlFilter: "||.", + url: "http://a.com./", + expectMatch: false, + }, + // {"^", kSubdomain, kAnchorNone} + { + urlFilter: "||^", + url: "http://a.com./", + expectMatch: true, + }, + // {".", kSubdomain, kAnchorNone} + { + urlFilter: "||.", + url: "http://a.com../", + expectMatch: true, + }, + // {"^", kSubdomain, kAnchorNone} + { + urlFilter: "||^", + url: "http://a.com../", + expectMatch: true, + }, + // {"/path", kSubdomain, kAnchorNone} + { + urlFilter: "||/path", + url: "http://a.com./path/to/x", + expectMatch: true, + }, + // {"^path", kSubdomain, kAnchorNone} + { + urlFilter: "||^path", + url: "http://a.com./path/to/x", + expectMatch: true, + }, + // {"/path", kSubdomain, kBoundary} + { + urlFilter: "||/path|", + url: "http://a.com./path", + expectMatch: true, + }, + // {"^path", kSubdomain, kBoundary} + { + urlFilter: "||^path|", + url: "http://a.com./path", + expectMatch: true, + }, + // {"path", kSubdomain, kBoundary} + { + urlFilter: "||path|", + url: "http://a.com./path", + expectMatch: false, + }, + // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kDonotMatchCase} + { + urlFilter: "path", + url: "http://a.com/PaTh", + isUrlFilterCaseSensitive: false, + expectMatch: true, + }, + // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kMatchCase} + { + urlFilter: "path", + url: "http://a.com/PaTh", + isUrlFilterCaseSensitive: true, + expectMatch: false, + }, + // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kDonotMatchCase} + { + urlFilter: "path", + url: "http://a.com/path", + isUrlFilterCaseSensitive: false, + expectMatch: true, + }, + // {"path", proto::URL_PATTERN_TYPE_SUBSTRING, kMatchCase} + { + urlFilter: "path", + url: "http://a.com/path", + isUrlFilterCaseSensitive: true, + expectMatch: true, + }, + // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kMatchCase} + { + urlFilter: "abc*def^", + url: "http://a.com/abcxAdef/vo", + isUrlFilterCaseSensitive: true, + expectMatch: true, + }, + // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kMatchCase} + { + urlFilter: "abc*def^", + url: "http://a.com/aBcxAdeF/vo", + isUrlFilterCaseSensitive: true, + expectMatch: false, + }, + // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kDonotMatchCase} + { + urlFilter: "abc*def^", + url: "http://a.com/aBcxAdeF/vo", + isUrlFilterCaseSensitive: false, + expectMatch: true, + }, + // {"abc*def^", proto::URL_PATTERN_TYPE_WILDCARDED, kDonotMatchCase} + { + urlFilter: "abc*def^", + url: "http://a.com/abcxAdef/vo", + isUrlFilterCaseSensitive: false, + expectMatch: true, + }, + // {"abc^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc^", + url: "https://xyz.com/abc/123", + expectMatch: true, + }, + // {"abc^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc^", + url: "https://xyz.com/abc", + expectMatch: true, + }, + // {"abc^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc^", + url: "https://abc.com", + expectMatch: false, + }, + // {"abc^", kAnchorNone, kBoundary} + { + urlFilter: "abc^|", + url: "https://xyz.com/abc/", + expectMatch: true, + }, + // {"abc^", kAnchorNone, kBoundary} + { + urlFilter: "abc^|", + url: "https://xyz.com/abc", + expectMatch: true, + }, + // {"abc^", kAnchorNone, kBoundary} + { + urlFilter: "abc^|", + url: "https://xyz.com/abc/123", + expectMatch: false, + }, + // {"http://abc.com/x^", kBoundary, kAnchorNone} + { + urlFilter: "|http://abc.com/x^", + url: "http://abc.com/x", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kAnchorNone} + { + urlFilter: "|http://abc.com/x^", + url: "http://abc.com/x/", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kAnchorNone} + { + urlFilter: "|http://abc.com/x^", + url: "http://abc.com/x/123", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kBoundary} + { + urlFilter: "|http://abc.com/x^|", + url: "http://abc.com/x", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kBoundary} + { + urlFilter: "|http://abc.com/x^|", + url: "http://abc.com/x/", + expectMatch: true, + }, + // {"http://abc.com/x^", kBoundary, kBoundary} + { + urlFilter: "|http://abc.com/x^|", + url: "http://abc.com/x/123", + expectMatch: false, + }, + // {"abc.com^", kSubdomain, kAnchorNone} + { + urlFilter: "||abc.com^", + url: "http://xyz.abc.com/123", + expectMatch: true, + }, + // {"abc.com^", kSubdomain, kAnchorNone} + { + urlFilter: "||abc.com^", + url: "http://xyz.abc.com", + expectMatch: true, + }, + // {"abc.com^", kSubdomain, kAnchorNone} + { + urlFilter: "||abc.com^", + url: "http://abc.com.xyz.com?q=abc.com", + expectMatch: false, + }, + // {"abc.com^", kSubdomain, kBoundary} + { + urlFilter: "||abc.com^|", + url: "http://xyz.abc.com/123", + expectMatch: false, + }, + // {"abc.com^", kSubdomain, kBoundary} + { + urlFilter: "||abc.com^|", + url: "http://xyz.abc.com", + expectMatch: true, + }, + // {"abc.com^", kSubdomain, kBoundary} + { + urlFilter: "||abc.com^|", + url: "http://abc.com.xyz.com?q=abc.com/", + expectMatch: false, + }, + // {"abc*^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc*^", + url: "https://abc.com", + expectMatch: true, + }, + // {"abc*^", kAnchorNone, kAnchorNone} + { + urlFilter: "abc*^", + url: "https://abc.com?q=123", + expectMatch: true, + }, + // {"abc*^", kAnchorNone, kBoundary} + { + urlFilter: "abc*^|", + url: "https://abc.com", + expectMatch: true, + }, + // {"abc*^", kAnchorNone, kBoundary} + { + urlFilter: "abc*^|", + url: "https://abc.com?q=123", + expectMatch: true, + }, + // {"abc*", kAnchorNone, kBoundary} + { + urlFilter: "abc*|", + url: "https://a.com/abcxyz", + expectMatch: true, + }, + // {"*google.com", kBoundary, kAnchorNone} + { + urlFilter: "|*google.com", + url: "https://www.google.com", + expectMatch: true, + }, + // {"*", kBoundary, kBoundary} + { + urlFilter: "|*|", + url: "https://example.com", + expectMatch: true, + }, + // // {"", kBoundary, kBoundary} + // { // "||" is ambiguous, cannot mean Left anchor + Right anchor + // urlFilter: "||", + // url: "https://example.com", + // expectMatch: false, + // }, + ]; + for (let test of testCases) { + let { urlFilter, url, expectMatch, isUrlFilterCaseSensitive } = test; + if (expectMatch) { + await testMatchesUrlFilter({ + urlFilter, + isUrlFilterCaseSensitive, + urls: [url], + }); + } else { + await testMatchesUrlFilter({ + urlFilter, + isUrlFilterCaseSensitive, + urlsNonMatching: [url], + }); + } + } + + browser.test.notifyPass(); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js new file mode 100644 index 0000000000..415ab42c5f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_webrequest.js @@ -0,0 +1,296 @@ +"use strict"; + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +const server = createHttpServer({ + hosts: ["example.com", "redir"], +}); +server.registerPathHandler("/never_reached", (req, res) => { + Assert.ok(false, "Server should never have been reached"); +}); +server.registerPathHandler("/source", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); +}); +server.registerPathHandler("/destination", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); +}); + +add_task(async function block_request_with_dnr() { + async function background() { + let onBeforeRequestPromise = new Promise(resolve => { + browser.webRequest.onBeforeRequest.addListener(resolve, { + urls: ["*://example.com/*"], + }); + }); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["example.com"] }, + action: { type: "block" }, + }, + ], + }); + + await browser.test.assertRejects( + fetch("http://example.com/never_reached"), + "NetworkError when attempting to fetch resource.", + "blocked by DNR rule" + ); + // DNR is documented to take precedence over webRequest. We should still + // receive the webRequest event, however. + browser.test.log("Waiting for webRequest.onBeforeRequest..."); + await onBeforeRequestPromise; + browser.test.log("Seen webRequest.onBeforeRequest!"); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: ["declarativeNetRequest", "webRequest"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function upgradeScheme_and_redirect_request_with_dnr() { + async function background() { + let onBeforeRequestSeen = []; + browser.webRequest.onBeforeRequest.addListener( + d => { + onBeforeRequestSeen.push(d.url); + // webRequest cancels, but DNR should actually be taking precedence. + return { cancel: true }; + }, + { urls: ["*://example.com/*", "http://redir/here"] }, + ["blocking"] + ); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["example.com"] }, + action: { type: "upgradeScheme" }, + }, + { + id: 2, + condition: { requestDomains: ["example.com"], urlFilter: "|https:*" }, + action: { type: "redirect", redirect: { url: "http://redir/here" } }, + // The upgradeScheme and redirect actions have equal precedence. To + // make sure that the redirect action is executed when both rules + // match, we assign a higher priority to the redirect action. + priority: 2, + }, + ], + }); + + await browser.test.assertRejects( + fetch("http://example.com/never_reached"), + "NetworkError when attempting to fetch resource.", + "although initially redirected by DNR, ultimately blocked by webRequest" + ); + // DNR is documented to take precedence over webRequest. + // So we should actually see redirects according to the DNR rules, and + // the webRequest listener should still be able to observe all requests. + browser.test.assertDeepEq( + [ + "http://example.com/never_reached", + "https://example.com/never_reached", + "http://redir/here", + ], + onBeforeRequestSeen, + "Expected onBeforeRequest events" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*", "*://redir/*"], + permissions: [ + "declarativeNetRequest", + "webRequest", + "webRequestBlocking", + ], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function block_request_with_webRequest_after_allow_with_dnr() { + async function background() { + let onBeforeRequestSeen = []; + browser.webRequest.onBeforeRequest.addListener( + d => { + onBeforeRequestSeen.push(d.url); + return { cancel: !d.url.includes("webRequestNoCancel") }; + }, + { urls: ["*://example.com/*"] }, + ["blocking"] + ); + // All DNR actions that do not end up canceling/redirecting the request: + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestMethods: ["get"] }, + action: { type: "allow" }, + }, + { + id: 2, + condition: { requestMethods: ["put"] }, + action: { + type: "modifyHeaders", + requestHeaders: [{ operation: "set", header: "x", value: "y" }], + }, + }, + ], + }); + + await browser.test.assertRejects( + fetch("http://example.com/never_reached?1", { method: "get" }), + "NetworkError when attempting to fetch resource.", + "despite DNR 'allow' rule, still blocked by webRequest" + ); + await browser.test.assertRejects( + fetch("http://example.com/never_reached?2", { method: "put" }), + "NetworkError when attempting to fetch resource.", + "despite DNR 'modifyHeaders' rule, still blocked by webRequest" + ); + // Just to rule out the request having been canceled by DNR instead of + // webRequest, repeat the requests and verify that they succeed. + await fetch("http://example.com/?webRequestNoCancel1", { method: "get" }); + await fetch("http://example.com/?webRequestNoCancel2", { method: "put" }); + + browser.test.assertDeepEq( + [ + "http://example.com/never_reached?1", + "http://example.com/never_reached?2", + "http://example.com/?webRequestNoCancel1", + "http://example.com/?webRequestNoCancel2", + ], + onBeforeRequestSeen, + "Expected onBeforeRequest events" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: [ + "declarativeNetRequest", + "webRequest", + "webRequestBlocking", + ], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function redirect_with_webRequest_after_failing_dnr_redirect() { + async function background() { + // Maximum length of a UTL is 1048576 (network.standard-url.max-length). + const network_standard_url_max_length = 1048576; + // updateSessionRules does some validation on the limit (as seen by + // validate_action_redirect_transform in test_ext_dnr_session_rules.js), + // but it is still possible to pass validation and fail in practice when + // the existing URL + new component exceeds the limit. + const VERY_LONG_STRING = "x".repeat(network_standard_url_max_length - 20); + + browser.webRequest.onBeforeRequest.addListener( + d => { + return { redirectUrl: "http://redir/destination?by-webrequest" }; + }, + { urls: ["*://example.com/*"] }, + ["blocking"] + ); + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["example.com"] }, + action: { + type: "redirect", + redirect: { + transform: { + host: "redir", + path: "/destination", + queryTransform: { + addOrReplaceParams: [ + { key: "dnr", value: VERY_LONG_STRING, replaceOnly: true }, + ], + }, + }, + }, + }, + }, + ], + }); + + // Note: we are not expecting successful DNR redirects below, but in case + // that ever changes (e.g. due to VERY_LONG_STRING not resulting in an + // invalid URL), we will truncate the URL out of caution. + // VERY_LONG_STRING consists of many 'X'. Shorten to avoid logspam. + const shortx = s => s.replace(/x{10,}/g, xxx => `x{${xxx.length}}`); + + browser.test.assertEq( + "http://redir/destination?1", + shortx((await fetch("http://example.com/never_reached?1")).url), + "Successful DNR redirect." + ); + + // DNR redirect failure is expected to be very rare, and only to occur when + // an extension intentionally explores the boundaries of the DNR API. When + // DNR fails, we fall back to allowing webRequest to take over. + browser.test.assertEq( + "http://redir/destination?by-webrequest", + shortx((await fetch("http://example.com/source?dnr")).url), + "When DNR fails, we fall back to webRequest redirect" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: [ + "declarativeNetRequest", + "webRequest", + "webRequestBlocking", + ], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js new file mode 100644 index 0000000000..48baa41c60 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dnr_without_webrequest.js @@ -0,0 +1,739 @@ +"use strict"; + +// This test file verifies that the declarativeNetRequest API can modify +// network requests as expected without the presence of the webRequest API. See +// test_ext_dnr_webRequest.js for the interaction between webRequest and DNR. + +add_setup(() => { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + Services.prefs.setBoolPref("extensions.dnr.enabled", true); +}); + +const server = createHttpServer({ + hosts: ["example.com", "example.net", "example.org", "redir", "dummy"], +}); +server.registerPathHandler("/cors_202", (req, res) => { + res.setStatusLine(req.httpVersion, 202, "Accepted"); + // The extensions in this test have minimal permissions, so grant CORS to + // allow them to read the response without host permissions. + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + res.write("cors_response"); +}); +server.registerPathHandler("/never_reached", (req, res) => { + Assert.ok(false, "Server should never have been reached"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); +}); +let gPreflightCount = 0; +server.registerPathHandler("/preflight_count", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + res.setHeader("Access-Control-Allow-Methods", "NONSIMPLE"); + if (req.method === "OPTIONS") { + ++gPreflightCount; + } else { + // CORS Preflight considers 2xx to be successful. To rule out inadvertent + // server opt-in to CORS, respond with a non-2xx response. + res.setStatusLine(req.httpVersion, 418, "I'm a teapot"); + res.write(`count=${gPreflightCount}`); + } +}); +server.registerPathHandler("/", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Max-Age", "0"); + res.write("Dummy page"); +}); + +async function contentFetch(initiatorURL, url, options) { + let contentPage = await ExtensionTestUtils.loadContentPage(initiatorURL); + // Sanity check: that the initiator is as specified, and not redirected. + Assert.equal( + await contentPage.spawn(null, () => content.document.URL), + initiatorURL, + `Expected document load at: ${initiatorURL}` + ); + let result = await contentPage.spawn({ url, options }, async args => { + try { + let req = await content.fetch(args.url, args.options); + return { + status: req.status, + url: req.url, + body: await req.text(), + }; + } catch (e) { + return { error: e.message }; + } + }); + await contentPage.close(); + return result; +} + +add_task(async function block_request_with_dnr() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestMethods: ["get"] }, + action: { type: "block" }, + }, + { + id: 2, + condition: { requestMethods: ["head"] }, + action: { type: "allow" }, + }, + ], + }); + { + // Request not matching DNR. + let req = await fetch("http://example.com/cors_202", { method: "post" }); + browser.test.assertEq(202, req.status, "allowed without DNR rule"); + browser.test.assertEq("cors_response", await req.text()); + } + { + // Request with "allow" DNR action. + let req = await fetch("http://example.com/cors_202", { method: "head" }); + browser.test.assertEq(202, req.status, "allowed by DNR rule"); + browser.test.assertEq("", await req.text(), "no response for HEAD"); + } + + // Request with "block" DNR action. + await browser.test.assertRejects( + fetch("http://example.com/never_reached", { method: "get" }), + "NetworkError when attempting to fetch resource.", + "blocked by DNR rule" + ); + + browser.test.sendMessage("tested_dnr_block"); + } + let extension = ExtensionTestUtils.loadExtension({ + allowInsecureRequests: true, + background, + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitMessage("tested_dnr_block"); + + // DNR should not only work with requests within the extension, but also from + // web pages. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://example.com/never_reached"), + { error: "NetworkError when attempting to fetch resource." }, + "Blocked by DNR with declarativeNetRequestWithHostAccess" + ); + + // The declarativeNetRequest permission grants the ability to block requests + // from other extensions. (The declarativeNetRequestWithHostAccess permission + // does not; see test task block_with_declarativeNetRequestWithHostAccess.) + let otherExtension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + fetch("http://example.com/never_reached", { method: "get" }), + "NetworkError when attempting to fetch resource.", + "blocked by different extension with declarativeNetRequest permission" + ); + browser.test.sendMessage("other_extension_done"); + }, + }); + await otherExtension.startup(); + await otherExtension.awaitMessage("other_extension_done"); + await otherExtension.unload(); + + await extension.unload(); +}); + +// Verifies that the "declarativeNetRequestWithHostAccess" permission can only +// block if it has permission for the initiator. +add_task(async function block_with_declarativeNetRequestWithHostAccess() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [{ id: 1, condition: {}, action: { type: "block" } }], + }); + browser.test.sendMessage("dnr_registered"); + }, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["<all_urls>"], + permissions: ["declarativeNetRequestWithHostAccess"], + }, + }); + await extension.startup(); + await extension.awaitMessage("dnr_registered"); + + // Initiator "http://dummy" does match "<all_urls>", so DNR rule should apply. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://example.com/never_reached"), + { error: "NetworkError when attempting to fetch resource." }, + "Blocked by DNR with declarativeNetRequestWithHostAccess" + ); + + // Extensions cannot have permissions for another extension and therefore the + // DNR rule never applies. + let otherExtension = ExtensionTestUtils.loadExtension({ + async background() { + let req = await fetch("http://example.com/cors_202", { method: "get" }); + browser.test.assertEq(202, req.status, "not blocked by other extension"); + browser.test.assertEq("cors_response", await req.text()); + browser.test.sendMessage("other_extension_done"); + }, + }); + await otherExtension.startup(); + await otherExtension.awaitMessage("other_extension_done"); + await otherExtension.unload(); + + await extension.unload(); +}); + +// Verifies that upgradeScheme works. +// The HttpServer helper does not support https (bug 1742061), so in this +// test we just verify whether the upgrade has been attempted. Coverage that +// verifies that the upgraded request completes is in: +// toolkit/components/extensions/test/mochitest/test_ext_dnr_upgradeScheme.html +add_task(async function upgradeScheme_declarativeNetRequestWithHostAccess() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { excludedRequestDomains: ["dummy"] }, + action: { type: "upgradeScheme" }, + }, + { + id: 2, + // HttpServer does not support https (bug 1742061). + // As a work-around, we just redirect the https:-request to http. + condition: { urlFilter: "|https:*" }, + action: { + type: "redirect", + redirect: { url: "http://dummy/cors_202?from_https" }, + }, + // The upgradeScheme and redirect actions have equal precedence. To + // make sure that the redirect action is executed when both rules + // match, we assign a higher priority to the redirect action. + priority: 2, + }, + ], + }); + + let req = await fetch("http://redir/never_reached"); + browser.test.assertEq( + "http://dummy/cors_202?from_https", + req.url, + "upgradeScheme upgraded to https" + ); + browser.test.assertEq("cors_response", await req.text()); + + browser.test.sendMessage("tested_dnr_upgradeScheme"); + }, + temporarilyInstalled: true, // Needed for granted_host_permissions. + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://dummy/*", "*://redir/*"], + permissions: ["declarativeNetRequestWithHostAccess"], + }, + }); + await extension.startup(); + await extension.awaitMessage("tested_dnr_upgradeScheme"); + + // Request to same-origin subresource, which should be upgraded. + Assert.equal( + (await contentFetch("http://redir/", "http://redir/never_reached")).url, + "http://dummy/cors_202?from_https", + "upgradeScheme + host access should upgrade (same-origin request)" + ); + + // Request to cross-origin subresource, which should be upgraded. + // Note: after the upgrade, a cross-origin redirect happens. Internally, we + // reflect the Origin request header in the Access-Control-Allow-Origin (ACAO) + // response header, to ensure that the request is accepted by CORS. See + // https://github.com/w3c/webappsec-upgrade-insecure-requests/issues/32 + Assert.equal( + (await contentFetch("http://dummy/", "http://redir/never_reached")).url, + "http://dummy/cors_202?from_https", + "upgradeScheme + host access should upgrade (cross-origin request)" + ); + + // The DNR extension does not have example.net in host_permissions. + const urlNoHostPerms = "http://example.net/cors_202?missing_host_permission"; + Assert.equal( + (await contentFetch("http://dummy/", urlNoHostPerms)).url, + urlNoHostPerms, + "upgradeScheme not matched when extension lacks host access" + ); + + await extension.unload(); +}); + +add_task(async function redirect_request_with_dnr() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + requestDomains: ["example.com"], + requestMethods: ["get"], + }, + action: { + type: "redirect", + redirect: { + url: "http://example.net/cors_202?1", + }, + }, + }, + { + id: 2, + // Note: extension does not have example.org host permission. + condition: { requestDomains: ["example.org"] }, + action: { + type: "redirect", + redirect: { + url: "http://example.net/cors_202?2", + }, + }, + }, + ], + }); + // The extension only has example.com permission, but the redirects to + // example.net are still due to the CORS headers from the server. + { + // Simple GET request. + let req = await fetch("http://example.com/never_reached"); + browser.test.assertEq(202, req.status, "redirected by DNR (simple)"); + browser.test.assertEq("http://example.net/cors_202?1", req.url); + browser.test.assertEq("cors_response", await req.text()); + } + { + // GeT request should be matched despite having a different case. + let req = await fetch("http://example.com/never_reached", { + method: "GeT", + }); + browser.test.assertEq(202, req.status, "redirected by DNR (GeT)"); + browser.test.assertEq("http://example.net/cors_202?1", req.url); + browser.test.assertEq("cors_response", await req.text()); + } + { + // Host permission missing for request, request not redirected by DNR. + // Response is readable due to the CORS response headers from the server. + let req = await fetch("http://example.org/cors_202?noredir"); + browser.test.assertEq(202, req.status, "not redirected by DNR"); + browser.test.assertEq("http://example.org/cors_202?noredir", req.url); + browser.test.assertEq("cors_response", await req.text()); + } + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://example.com/*"], + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + + let otherExtension = ExtensionTestUtils.loadExtension({ + async background() { + // The DNR extension has permissions for example.com, but not for this + // extension. Therefore the "redirect" action should not apply. + let req = await fetch("http://example.com/cors_202?other_ext"); + browser.test.assertEq(202, req.status, "not redirected by DNR"); + browser.test.assertEq("http://example.com/cors_202?other_ext", req.url); + browser.test.assertEq("cors_response", await req.text()); + browser.test.sendMessage("other_extension_done"); + }, + }); + await otherExtension.startup(); + await otherExtension.awaitMessage("other_extension_done"); + await otherExtension.unload(); + + await extension.unload(); +}); + +// Verifies that DNR redirects requiring a CORS preflight behave as expected. +add_task(async function redirect_request_with_dnr_cors_preflight() { + // Most other test tasks only test requests within the test extension. This + // test intentionally triggers requests outside the extension, to make sure + // that the usual CORS mechanisms is triggered (instead of exceptions from + // host permissions). + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { + requestDomains: ["redir"], + excludedRequestMethods: ["options"], + }, + action: { + type: "redirect", + redirect: { + url: "http://example.com/preflight_count", + }, + }, + }, + { + id: 2, + condition: { + requestDomains: ["example.net"], + excludedRequestMethods: ["nonsimple"], // note: redirects "options" + }, + action: { + type: "redirect", + redirect: { + url: "http://example.com/preflight_count", + }, + }, + }, + ], + }); + let req = await fetch("http://redir/never_reached", { + method: "NONSIMPLE", + }); + // Extension has permission for "redir", but not for the redirect target. + // The request is non-simple (see below for explanation of non-simple), so + // a preflight (OPTIONS) request to /preflight_count is expected before the + // redirection target is requested. + browser.test.assertEq( + "count=1", + await req.text(), + "Got preflight before redirect target because of missing host_permissions" + ); + + browser.test.sendMessage("continue_preflight_tests"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + // "redir" and "example.net" are needed to allow redirection of these. + // "dummy" is needed to redirect requests initiated from http://dummy. + host_permissions: ["*://redir/*", "*://example.net/*", "*://dummy/*"], + permissions: ["declarativeNetRequest"], + }, + }); + gPreflightCount = 0; + await extension.startup(); + await extension.awaitMessage("continue_preflight_tests"); + gPreflightCount = 0; // value already checked before continue_preflight_tests. + + // Simple request (i.e. without preflight requirement), that's redirected to + // another URL by the DNR rule. The redirect should be accepted, and in + // particular not be blocked by the same-origin policy. The redirect target + // (/preflight_count) is readable due to the CORS headers from the server. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://redir/never_reached"), + // count=0: A simple request does not trigger a preflight (OPTIONS) request. + { status: 418, url: "http://example.com/preflight_count", body: "count=0" }, + "Simple request should not have a preflight." + ); + + // Any request method other than "GET", "POST" and "PUT" (e.g "NONSIMPLE") is + // a non-simple request that triggers a preflight request ("OPTIONS"). + // + // Usually, this happens (without extension-triggered redirects): + // 1. NONSIMPLE /never_reached : is started, but does NOT hit the server yet. + // 2. OPTIONS /never_reached + Access-Control-Request-Method: NONSIMPLE + // 3. NONSIMPLE /never_reached : reaches the server if allowed by OPTIONS. + // + // With an extension-initiated redirect to /preflight_count: + // 1. NONSIMPLE /never_reached : is started, but does not hit the server yet. + // 2. extension redirects to /preflight_count + // 3. OPTIONS /preflight_count + Access-Control-Request-Method: NONSIMPLE + // - This is because the redirect preserves the request method/body/etc. + // 4. NONSIMPLE /preflight_count : reaches the server if allowed by OPTIONS. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://redir/never_reached", { + method: "NONSIMPLE", + }), + // Due to excludedRequestMethods: ["options"], the preflight for the + // redirect target is not intercepted, so the server sees a preflight. + { status: 418, url: "http://example.com/preflight_count", body: "count=1" }, + "Initial URL redirected, redirection target has preflight" + ); + gPreflightCount = 0; + + // The "example.net" rule has "excludedRequestMethods": ["nonsimple"], so the + // initial "NONSIMPLE" request is not immediately redirected. Therefore the + // preflight request happens. This OPTIONS request is matched by the DNR rule + // and redirected to /preflight_count. While preflight_count offers a very + // permissive preflight response, it is not even fetched: + // Only a 2xx HTTP status is considered a valid response to a pre-flight. + // A redirect is like a 3xx HTTP status, so the whole request is rejected, + // and the redirect is not followed for the OPTIONS request. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://example.net/never_reached", { + method: "NONSIMPLE", + }), + { error: "NetworkError when attempting to fetch resource." }, + "Redirect of preflight request (OPTIONS) should be a CORS failure" + ); + + Assert.equal(gPreflightCount, 0, "Preflight OPTIONS has been intercepted"); + + await extension.unload(); +}); + +// Tests that DNR redirect rules can be chained. +add_task(async function redirect_request_with_dnr_multiple_hops() { + async function background() { + // Set up redirects from example.com up until dummy. + let hosts = ["example.com", "example.net", "example.org", "redir", "dummy"]; + let rules = []; + for (let i = 1; i < hosts.length; ++i) { + const from = hosts[i - 1]; + const to = hosts[i]; + const end = hosts.length - 1 === i; + rules.push({ + id: i, + condition: { requestDomains: [from] }, + action: { + type: "redirect", + redirect: { + // All intermediate redirects should never hit the server, but the + // last one should.. + url: end ? `http://${to}/?end` : `http://${to}/never_reached`, + }, + }, + }); + } + await browser.declarativeNetRequest.updateSessionRules({ addRules: rules }); + let req = await fetch("http://example.com/never_reached"); + browser.test.assertEq(200, req.status, "redirected by DNR (multiple)"); + browser.test.assertEq("http://dummy/?end", req.url, "Last URL in chain"); + browser.test.assertEq("Dummy page", await req.text()); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://*/*"], // matches all in the |hosts| list. + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + + // Test again, but without special extension permissions to verify that DNR + // redirects pass CORS checks. + Assert.deepEqual( + await contentFetch("http://dummy/", "http://redir/never_reached"), + { status: 200, url: "http://dummy/?end", body: "Dummy page" }, + "Multiple redirects by DNR, requested from web origin." + ); + + await extension.unload(); +}); + +add_task(async function redirect_request_with_dnr_with_redirect_loop() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["redir"] }, + action: { + type: "redirect", + redirect: { + url: "http://redir/cors_202?loop", + }, + }, + }, + ], + }); + + // Redirect with initially a different URL. + await browser.test.assertRejects( + fetch("http://redir/never_reached?"), + "NetworkError when attempting to fetch resource.", + "Redirect loop caught (initially different URL)" + ); + + // Redirect where redirect is exactly the same URL as requested. + await browser.test.assertRejects( + fetch("http://redir/cors_202?loop"), + "NetworkError when attempting to fetch resource.", + "Redirect loop caught (redirect target same as initial URL)" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://redir/*"], + permissions: ["declarativeNetRequest"], + }, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +// Tests that redirect to extensionPath works, provided that the initiator is +// either the extension itself, or in host_permissions. Moreover, the requested +// resource must match a web_accessible_resources entry for both the initiator +// AND the pre-redirect URL. +add_task(async function redirect_request_with_dnr_to_extensionPath() { + async function background() { + await browser.declarativeNetRequest.updateSessionRules({ + addRules: [ + { + id: 1, + condition: { requestDomains: ["redir"], requestMethods: ["post"] }, + action: { + type: "redirect", + redirect: { + extensionPath: "/war.txt?1", + }, + }, + }, + { + id: 2, + condition: { requestDomains: ["redir"], requestMethods: ["put"] }, + action: { + type: "redirect", + redirect: { + extensionPath: "/nonwar.txt?2", + }, + }, + }, + ], + }); + { + let req = await fetch("http://redir/never_reached", { method: "post" }); + browser.test.assertEq(200, req.status, "redirected to extensionPath"); + if (navigator.userAgent.includes("Android")) { + // See https://bugzilla.mozilla.org/show_bug.cgi?id=1745761#c7 + // When extensions.webextensions.remote is false (e.g. on Android), + // a redirect to a moz-extension:-URL reveals the underlying jar/file + // URL, instead of the moz-extension:-URL. + // TODO bug 1802385: fix bug and remove this Android-only check. + browser.test.assertTrue(req.url.endsWith("/war.txt?1"), req.url); + browser.test.assertFalse( + req.url.startsWith(location.origin), + "Work-around for bug 1802385 only needed if URL is not moz-extension:" + ); + } else { + browser.test.assertEq(`${location.origin}/war.txt?1`, req.url); + } + browser.test.assertEq("war_ext_res", await req.text()); + } + // Redirects to extensionPath that is not in web_accessible_resources. + // While the initiator (extension) would be allowed to read the resource + // due to it being same-origin, the pre-redirect URL (http://redir) is not + // matching web_accessible_resources[].matches, so the load is rejected. + // + // This behavior differs from Chrome (e.g. at least in Chrome 109) that + // does allow the load to complete. Extensions who really care about + // exposing a web-accessible resource to the world can just put an all_urls + // pattern in web_accessible_resources[].matches. + await browser.test.assertRejects( + fetch("http://redir/never_reached", { method: "put" }), + "NetworkError when attempting to fetch resource.", + "Redirect to nowar.txt, but pre-redirect host is not in web_accessible_resources[].matches" + ); + + browser.test.notifyPass(); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + temporarilyInstalled: true, // Needed for granted_host_permissions + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + granted_host_permissions: true, + host_permissions: ["*://redir/*", "*://dummy/*"], + permissions: ["declarativeNetRequest"], + web_accessible_resources: [ + // *://redir/* is in matches, because that is the pre-redirect host. + // *://dummy/* is in matches, because that is an initiator below. + { resources: ["war.txt"], matches: ["*://redir/*", "*://dummy/*"] }, + // without "matches", this is almost equivalent to not being listed in + // web_accessible_resources at all. This entry is listed here to verify + // that the presence of extension_ids does not somehow allow a request + // with an extension initiator to complete. + { resources: ["nonwar.txt"], extension_ids: ["*"] }, + ], + }, + files: { + "war.txt": "war_ext_res", + "nonwar.txt": "non_war_ext_res", + }, + }); + await extension.startup(); + await extension.awaitFinish(); + const extPrefix = `moz-extension://${extension.uuid}`; + + // Request from origin in host_permissions, for web-accessible resource. + Assert.deepEqual( + await contentFetch( + "http://dummy/", // <-- Matching web_accessible_resources[].matches + "http://redir/never_reached", // <-- With matching host_permissions + { method: "post" } + ), + { status: 200, url: `${extPrefix}/war.txt?1`, body: "war_ext_res" }, + "Should have got redirect to web_accessible_resources (war.txt)" + ); + + // Request from origin in host_permissions, for non-web-accessible resource. + let { messages } = await promiseConsoleOutput(async () => { + Assert.deepEqual( + await contentFetch( + "http://dummy/", // <-- Matching web_accessible_resources[].matches + "http://redir/never_reached", // <-- With matching host_permissions + { method: "put" } + ), + { error: "NetworkError when attempting to fetch resource." }, + "Redirect to nowar.txt, without matching web_accessible_resources[].matches" + ); + }); + const EXPECTED_SECURITY_ERROR = `Content at http://redir/never_reached may not load or link to ${extPrefix}/nonwar.txt?2.`; + Assert.equal( + messages.filter(m => m.message.includes(EXPECTED_SECURITY_ERROR)).length, + 1, + `Should log SecurityError: ${EXPECTED_SECURITY_ERROR}` + ); + + // Request from origin not in host_permissions. DNR rule should not apply. + Assert.deepEqual( + await contentFetch( + "http://dummy/", // <-- Matching web_accessible_resources[].matches + "http://example.com/cors_202", // <-- NOT in host_permissions + { method: "post" } + ), + { status: 202, url: "http://example.com/cors_202", body: "cors_response" }, + "Extension should not have redirected, due to lack of host permissions" + ); + + await extension.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_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js new file mode 100644 index 0000000000..e79e3adbfb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookieStoreId.js @@ -0,0 +1,469 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function cookiesToMime(cookies) { + return `dummy/${encodeURIComponent(cookies)}`.toLowerCase(); +} + +function mimeToCookies(mime) { + return decodeURIComponent(mime.replace("dummy/", "")); +} + +const server = createHttpServer({ hosts: ["example.net"] }); + +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", cookiesToMime(cookies)); + // Response of length 7. + response.write("1234567"); +}); + +const DOWNLOAD_URL = "http://example.net/download"; + +async function setUpCookies() { + Services.cookies.removeAll(); + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["cookies", "http://example.net/download"], + }, + async background() { + let url = "http://example.net/download"; + // Add default cookie + await browser.cookies.set({ + url, + name: "cookie_normal", + value: "1", + }); + + // Add private cookie + await browser.cookies.set({ + url, + storeId: "firefox-private", + name: "cookie_private", + value: "1", + }); + + // Add container cookie + await browser.cookies.set({ + url, + storeId: "firefox-container-1", + name: "cookie_container", + value: "1", + }); + browser.test.sendMessage("cookies set"); + }, + }); + await extension.startup(); + await extension.awaitMessage("cookies set"); + await extension.unload(); +} + +function createDownloadTestExtension(extraPermissions = [], incognito = false) { + let extensionOptions = { + manifest: { + permissions: ["downloads", ...extraPermissions], + }, + background() { + browser.test.onMessage.addListener(async (method, data) => { + async function getDownload(data) { + let donePromise = new Promise(resolve => { + browser.downloads.onChanged.addListener(async delta => { + if (delta.state?.current === "complete") { + resolve(delta.id); + } + }); + }); + let downloadId = await browser.downloads.download(data); + 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"); + return download; + } + function checkDownloadError(data) { + return browser.test.assertRejects( + browser.downloads.download(data.downloadData), + data.exceptionRe + ); + } + function search(data) { + return browser.downloads.search(data); + } + function erase(data) { + return browser.downloads.erase(data); + } + switch (method) { + case "getDownload": + return browser.test.sendMessage(method, await getDownload(data)); + case "checkDownloadError": + return browser.test.sendMessage( + method, + await checkDownloadError(data) + ); + case "search": + return browser.test.sendMessage(method, await search(data)); + case "erase": + return browser.test.sendMessage(method, await erase(data)); + } + }); + }, + }; + if (incognito) { + extensionOptions.incognitoOverride = "spanning"; + } + return ExtensionTestUtils.loadExtension(extensionOptions); +} + +function getResult(extension, method, data) { + extension.sendMessage(method, data); + return extension.awaitMessage(method); +} + +async function getCookies(extension, data) { + let download = await getResult(extension, "getDownload", data); + // The "/download" endpoint mirrors received cookies via Content-Type. + let cookies = mimeToCookies(download.mime); + return cookies; +} + +async function runTests(extension, containerDownloadAllowed, privateAllowed) { + let forcedIncognitoException = null; + if (!privateAllowed) { + forcedIncognitoException = /private browsing access not allowed/; + } else if (!containerDownloadAllowed) { + forcedIncognitoException = /No permission for cookieStoreId/; + } + + // Test default container download + if (containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-default", + }), + "cookie_normal=1", + "Default container cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-default", + }, + }); + } + + // Test private container download + if (privateAllowed && containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-private", + incognito: true, + }), + "cookie_private=1", + "Private container cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: forcedIncognitoException, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-private", + incognito: true, + }, + }); + } + + // Test firefox-container-1 download + if (containerDownloadAllowed) { + equal( + await getCookies(extension, { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }), + "cookie_container=1", + "firefox-container-1 cookies for downloads.download" + ); + } else { + await getResult(extension, "checkDownloadError", { + exceptionRe: /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }, + }); + } + + // Test mismatched incognito and cookieStoreId download + await getResult(extension, "checkDownloadError", { + exceptionRe: forcedIncognitoException + ? forcedIncognitoException + : /Illegal to set non-private cookieStoreId in a private window/, + downloadData: { + url: DOWNLOAD_URL, + incognito: true, + cookieStoreId: "firefox-container-1", + }, + }); + await getResult(extension, "checkDownloadError", { + exceptionRe: containerDownloadAllowed + ? /Illegal to set private cookieStoreId in a non-private window/ + : /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + incognito: false, + cookieStoreId: "firefox-private", + }, + }); + + // Test invalid cookieStoreId download + await getResult(extension, "checkDownloadError", { + exceptionRe: containerDownloadAllowed + ? /Illegal cookieStoreId/ + : /No permission for cookieStoreId/, + downloadData: { + url: DOWNLOAD_URL, + cookieStoreId: "invalid-invalid-invalid", + }, + }); + + let searchRes, searchResDownload; + // Test default container search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-default", + }); + equal( + searchRes.length, + 1, + "Default container results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_normal=1", + "Default container cookies for downloads.search" + ); + // Test default container search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 0, + "Default container results length for downloads.search when container mismatched" + ); + + // Test private container search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-private", + }); + if (privateAllowed) { + equal( + searchRes.length, + 1, + "Private container results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_private=1", + "Private container cookies for downloads.search" + ); + // Test private container search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 0, + "Private container results length for downloads.search when container mismatched" + ); + } else { + equal( + searchRes.length, + 0, + "Private container results length for downloads.search when private disallowed" + ); + } + + // Test firefox-container-1 search + searchRes = await getResult(extension, "search", { + cookieStoreId: "firefox-container-1", + }); + equal( + searchRes.length, + 1, + "firefox-container-1 results length for downloads.search" + ); + [searchResDownload] = searchRes; + equal( + mimeToCookies(searchResDownload.mime), + "cookie_container=1", + "firefox-container-1 cookies for downloads.search" + ); + // Test firefox-container-1 search with mismatched container + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + cookieStoreId: "firefox-default", + }); + equal( + searchRes.length, + 0, + "firefox-container-1 container results length for downloads.search when container mismatched" + ); + + // Test default container erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_normal=1"), + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + }); + equal( + searchRes.length, + 1, + "Default container results length for downloads.search after erase with mismatched container" + ); + + // Test private container erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_private=1"), + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + equal( + searchRes.length, + privateAllowed ? 1 : 0, + "Private container results length for downloads.search after erase with mismatched container" + ); + + // Test firefox-container-1 erase with mismatched container + await getResult(extension, "erase", { + mime: cookiesToMime("cookie_container=1"), + cookieStoreId: "firefox-default", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + }); + equal( + searchRes.length, + 1, + "firefox-container-1 results length for downloads.search after erase with mismatched container" + ); + + // Test default container erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-default", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_normal=1"), + }); + equal( + searchRes.length, + 0, + "Default container results length for downloads.search after erase" + ); + + // Test private container erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-private", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + // The following will also pass when incognito disabled + equal( + searchRes.length, + 0, + "Private container results length for downloads.search after erase" + ); + + // Test firefox-container-1 erase + await getResult(extension, "erase", { + cookieStoreId: "firefox-container-1", + }); + searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_container=1"), + }); + equal( + searchRes.length, + 0, + "firefox-container-1 results length for downloads.search after erase" + ); +} + +async function populateDownloads(extension) { + await getResult(extension, "erase", {}); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + }); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + incognito: true, + }); + await getResult(extension, "getDownload", { + url: DOWNLOAD_URL, + cookieStoreId: "firefox-container-1", + }); +} + +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); + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + await setUpCookies(); + registerCleanupFunction(() => { + Services.cookies.removeAll(); + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + downloadDir.remove(false); + }); +}); + +add_task(async function download_cookieStoreId() { + // Test extension with cookies permission and incognito enabled + let extension = createDownloadTestExtension(["cookies"], true); + await extension.startup(); + await runTests(extension, true, true); + + // Test extension with incognito enabled and no cookies permission + await populateDownloads(extension); + let noCookiesExtension = createDownloadTestExtension([], true); + await noCookiesExtension.startup(); + await runTests(noCookiesExtension, false, true); + await noCookiesExtension.unload(); + + // Test extension with incognito disabled and no cookies permission + await populateDownloads(extension); + let noCookiesAndPrivateExtension = createDownloadTestExtension([], false); + await noCookiesAndPrivateExtension.startup(); + await runTests(noCookiesAndPrivateExtension, false, false); + await noCookiesAndPrivateExtension.unload(); + + // Verify that incognito disabled test did not delete private download + let searchRes = await getResult(extension, "search", { + mime: cookiesToMime("cookie_private=1"), + }); + ok(searchRes.length, "Incognito disabled does not delete private download"); + + 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..2ba202b963 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js @@ -0,0 +1,219 @@ +/* -*- 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); + Services.prefs.setBoolPref("dom.security.https_first_pbm", false); + + 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"); + Services.prefs.clearUserPref("dom.security.https_first_pbm"); +}); 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..d9e85772ab --- /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.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +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_eventpage.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js new file mode 100644 index 0000000000..9c71c63e96 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_eventpage.js @@ -0,0 +1,162 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +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.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); + }); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_downloads_event_page() { + await AddonTestUtils.promiseStartupManager(); + + // A simple download driving extension + let dl_extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "downloader@mochitest" } }, + permissions: ["downloads"], + background: { persistent: false }, + }, + background() { + let downloadId; + browser.downloads.onChanged.addListener(async info => { + if (info.state && info.state.current === "complete") { + browser.test.sendMessage("downloadComplete"); + } + }); + browser.test.onMessage.addListener(async (msg, opts) => { + if (msg == "download") { + downloadId = await browser.downloads.download(opts); + } + if (msg == "erase") { + await browser.downloads.removeFile(downloadId); + await browser.downloads.erase({ id: downloadId }); + } + }); + }, + }); + await dl_extension.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["downloads"], + background: { persistent: false }, + }, + background() { + browser.downloads.onChanged.addListener(() => { + browser.test.sendMessage("onChanged"); + }); + browser.downloads.onCreated.addListener(() => { + browser.test.sendMessage("onCreated"); + }); + browser.downloads.onErased.addListener(() => { + browser.test.sendMessage("onErased"); + }); + browser.test.sendMessage("ready"); + }, + }); + + // onDeterminingFilename is never persisted, it is an empty event handler. + const EVENTS = ["onChanged", "onCreated", "onErased"]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "downloads", event, { + primed: false, + }); + } + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "downloads", event, { + primed: true, + }); + } + + // test download events waken background + dl_extension.sendMessage("download", { + url: TXT_URL, + filename: TXT_FILE, + }); + await extension.awaitMessage("ready"); + await extension.awaitMessage("onCreated"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "downloads", event, { + primed: false, + }); + } + await extension.awaitMessage("onChanged"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + ok( + !extension.extension.backgroundContext, + "Background Extension context should have been destroyed" + ); + + await dl_extension.awaitMessage("downloadComplete"); + dl_extension.sendMessage("erase"); + await extension.awaitMessage("ready"); + await extension.awaitMessage("onErased"); + await dl_extension.unload(); + + // check primed listeners after startup + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "downloads", event, { + primed: true, + }); + } + + 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..b04dd77301 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js @@ -0,0 +1,1073 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +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() { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all( + downloads.map(async download => { + await download.finalize(true); + 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(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + await clearDownloads(); + downloadDir.remove(true); + }); + + 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_partitionKey.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js new file mode 100644 index 0000000000..3326ed0ce9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_partitionKey.js @@ -0,0 +1,199 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TEST_FILE = "file_download.txt"; +const TEST_URL = BASE + "/" + TEST_FILE; + +// We use different cookieBehaviors so that we can verify if we use the correct +// cookieBehavior if option.incognito is set. Note that we need to set a +// non-default value to the private cookieBehavior because the private +// cookieBehavior will mirror the regular cookieBehavior if the private pref is +// default value and the regular pref is non-default value. To avoid affecting +// the test by mirroring, we set the private cookieBehavior to a non-default +// value. +const TEST_REGULAR_COOKIE_BEHAVIOR = + Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER; +const TEST_PRIVATE_COOKIE_BEHAVIOR = Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN; + +let downloadDir; + +function observeDownloadChannel(uri, partitionKey, isPrivate) { + return new Promise(resolve => { + let observer = { + observe(subject, topic, data) { + if (topic === "http-on-modify-request") { + let httpChannel = subject.QueryInterface(Ci.nsIHttpChannel); + if (httpChannel.URI.spec != uri) { + return; + } + + let reqLoadInfo = httpChannel.loadInfo; + let cookieJarSettings = reqLoadInfo.cookieJarSettings; + + // Check the partitionKey of the cookieJarSettings. + equal( + cookieJarSettings.partitionKey, + partitionKey, + "The loadInfo has the correct paritionKey" + ); + + // Check the cookieBehavior of the cookieJarSettings. + equal( + cookieJarSettings.cookieBehavior, + isPrivate + ? TEST_PRIVATE_COOKIE_BEHAVIOR + : TEST_REGULAR_COOKIE_BEHAVIOR, + "The loadInfo has the correct cookieBehavior" + ); + + Services.obs.removeObserver(observer, "http-on-modify-request"); + resolve(); + } + }, + }; + + Services.obs.addObserver(observer, "http-on-modify-request"); + }); +} + +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())); +} + +function backgroundScript() { + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + let options = args[0]; + + 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, + }); + } + } + }); + + browser.test.sendMessage("ready"); +} + +// Remove a file in the downloads directory. +function remove(filename, recursive = false) { + let file = downloadDir.clone(); + file.append(filename); + file.remove(recursive); +} + +add_task(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 + ); + Services.prefs.setBoolPref("privacy.partition.network_state", true); + + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + TEST_REGULAR_COOKIE_BEHAVIOR + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior.pbmode", + TEST_PRIVATE_COOKIE_BEHAVIOR + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("privacy.partition.network_state"); + Services.prefs.clearUserPref("network.cookie.cookieBehavior"); + Services.prefs.clearUserPref("network.cookie.cookieBehavior.pbmode"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + info(`Leftover file ${entry.path} in download directory`); + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +}); + +add_task(async function test() { + 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(url, partitionKey, isPrivate) { + let options = { url, incognito: isPrivate }; + + let promiseObserveDownloadChannel = observeDownloadChannel( + url, + partitionKey, + isPrivate + ); + + let msg = await download(options); + equal(msg.status, "success", `downloads.download() works`); + + await promiseObserveDownloadChannel; + await waitForDownloads(); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + info("extension started"); + + // Call download() to check partitionKey of the download channel for the + // regular browsing mode. + await testDownload( + TEST_URL, + `(http,localhost,${server.identity.primaryPort})`, + false + ); + remove(TEST_FILE); + + // Call download again for the private browsing mode. + await testDownload( + TEST_URL, + `(http,localhost,${server.identity.primaryPort})`, + true + ); + remove(TEST_FILE); + + 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..cbda5dc286 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js @@ -0,0 +1,306 @@ +/* -*- 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.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); + }); +}); + +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: { + browser_specific_settings: { gecko: { id: "@spanning" } }, + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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..98ce1dad2f --- /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.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +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..11e293519e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js @@ -0,0 +1,257 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.importESModule( + "resource://gre/modules/Downloads.sys.mjs" +); + +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_NAME_ENCODED_4 = "file%E3%80%82encode.txt"; + const FILE_NAME_DECODED_4 = "file\u3002encode.txt"; + const FILE_NAME_ENCODED_URL_4 = BASE + "/" + FILE_NAME_ENCODED_4; + 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; + + msg = await download({ url: FILE_NAME_ENCODED_URL_4 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded4 = 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, + }); + + await checkDownloadItem(downloadIds.fileEncoded4, { + url: FILE_NAME_ENCODED_URL_4, + filename: downloadPath(FILE_NAME_DECODED_4), + 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 checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_4) }, + ["fileEncoded4"], + "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_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js new file mode 100644 index 0000000000..6393180dbe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_idle.js @@ -0,0 +1,575 @@ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +Services.prefs.setBoolPref("extensions.eventPages.enabled", true); +// Set minimum idle timeout for testing +Services.prefs.setIntPref("extensions.background.idle.timeout", 0); + +// Expected rejection from the test cases defined in this file. +PromiseTestUtils.allowMatchingRejectionsGlobally(/expected-test-rejection/); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Actor 'Conduits' destroyed before query 'RunListener' was resolved/ +); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_eventpage_idle() { + clearHistograms(); + + assertHistogramEmpty(WEBEXT_EVENTPAGE_RUNNING_TIME_MS); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID); + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings"], + background: { persistent: false }, + }, + background() { + browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( + () => { + browser.test.sendMessage("allowPopupsForUserEvents"); + } + ); + browser.runtime.onSuspend.addListener(async () => { + let setting = await browser.browserSettings.allowPopupsForUserEvents.get( + {} + ); + browser.test.sendMessage("suspended", setting); + }); + }, + }); + await extension.startup(); + assertPersistentListeners( + extension, + "browserSettings", + "allowPopupsForUserEvents", + { + primed: false, + } + ); + + info(`test idle timeout after startup`); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + assertPersistentListeners( + extension, + "browserSettings", + "allowPopupsForUserEvents", + { + primed: true, + } + ); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + await extension.awaitMessage("allowPopupsForUserEvents"); + ok(true, "allowPopupsForUserEvents.onChange fired"); + + // again after the event is fired + info(`test idle timeout after wakeup`); + let setting = await extension.awaitMessage("suspended"); + equal(setting.value, true, "verify simple async wait works in onSuspend"); + + await promiseExtensionEvent(extension, "shutdown-background-script"); + assertPersistentListeners( + extension, + "browserSettings", + "allowPopupsForUserEvents", + { + primed: true, + } + ); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + false + ); + await extension.awaitMessage("allowPopupsForUserEvents"); + ok(true, "allowPopupsForUserEvents.onChange fired"); + + const { id } = extension; + await extension.unload(); + + info("Verify eventpage telemetry recorded"); + + assertHistogramSnapshot( + WEBEXT_EVENTPAGE_RUNNING_TIME_MS, + { + keyed: false, + processSnapshot: snapshot => snapshot.sum > 0, + expectedValue: true, + }, + `Expect stored values in the eventpage running time non-keyed histogram snapshot` + ); + + assertHistogramSnapshot( + WEBEXT_EVENTPAGE_RUNNING_TIME_MS_BY_ADDONID, + { + keyed: true, + processSnapshot: snapshot => snapshot[id]?.sum > 0, + expectedValue: true, + }, + `Expect stored values for addon with id ${id} in the eventpage running time keyed histogram snapshot` + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "suspend", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: id, + category: "suspend", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); +}); + +add_task( + { pref_set: [["extensions.webextensions.runtime.timeout", 500]] }, + async function test_eventpage_runtime_onSuspend_timeout() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + background() { + browser.runtime.onSuspend.addListener(() => { + // return a promise that never resolves + return new Promise(() => {}); + }); + }, + }); + await extension.startup(); + await promiseExtensionEvent(extension, "shutdown-background-script"); + ok(true, "onSuspend did not block background shutdown"); + await extension.unload(); + } +); + +add_task( + { pref_set: [["extensions.webextensions.runtime.timeout", 500]] }, + async function test_eventpage_runtime_onSuspend_reject() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + background() { + browser.runtime.onSuspend.addListener(() => { + // Raise an error to test error handling in onSuspend + return Promise.reject("testing reject"); + }); + }, + }); + await extension.startup(); + await promiseExtensionEvent(extension, "shutdown-background-script"); + ok(true, "onSuspend did not block background shutdown"); + await extension.unload(); + } +); + +add_task( + { pref_set: [["extensions.webextensions.runtime.timeout", 1000]] }, + async function test_eventpage_runtime_onSuspend_canceled() { + clearHistograms(); + + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings"], + background: { persistent: false }, + }, + background() { + let resolveSuspend; + browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( + () => { + browser.test.sendMessage("allowPopupsForUserEvents"); + } + ); + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("suspending"); + return new Promise(resolve => { + resolveSuspend = resolve; + }); + }); + browser.runtime.onSuspendCanceled.addListener(() => { + browser.test.sendMessage("suspendCanceled"); + }); + browser.test.onMessage.addListener(() => { + resolveSuspend(); + }); + }, + }); + await extension.startup(); + await extension.awaitMessage("suspending"); + // While suspending, cause an event + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + extension.sendMessage("resolveSuspend"); + await extension.awaitMessage("allowPopupsForUserEvents"); + await extension.awaitMessage("suspendCanceled"); + ok(true, "event caused suspend-canceled"); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "reset_event", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: extension.id, + category: "reset_event", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); + + await extension.awaitMessage("suspending"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + await extension.unload(); + } +); + +add_task(async function test_terminateBackground_after_extension_hasShutdown() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + async background() { + browser.runtime.onSuspend.addListener(() => { + browser.test.fail( + `runtime.onSuspend listener should have not been called` + ); + }); + + // Call an API method implemented in the parent process (to be sure runtime.onSuspend + // listener is going to be fully registered from a parent process perspective by the + // time we will send the "bg-ready" test message). + await browser.runtime.getBrowserInfo(); + + browser.test.sendMessage("bg-ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ready"); + + // Fake suspending event page on idle while the extension was being shutdown by manually + // setting the hasShutdown flag to true on the Extension class instance object. + extension.extension.hasShutdown = true; + await extension.terminateBackground(); + extension.extension.hasShutdown = false; + + await extension.unload(); +}); + +add_task(async function test_wakeupBackground_after_extension_hasShutdown() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + async background() { + browser.test.sendMessage("bg-ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ready"); + await extension.terminateBackground(); + + // Fake suspending event page on idle while the extension was being shutdown by manually + // setting the hasShutdown flag to true on the Extension class instance object. + extension.extension.hasShutdown = true; + await Assert.rejects( + extension.wakeupBackground(), + /wakeupBackground called while the extension was already shutting down/, + "Got the expected rejection when wakeupBackground is called after extension shutdown" + ); + extension.extension.hasShutdown = false; + + await extension.unload(); +}); + +async function testSuspendShutdownRace({ manifest_version }) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + background: manifest_version === 2 ? { persistent: false } : {}, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["*://example.com/*"], + granted_host_permissions: true, + }, + // Define an empty background script. + background() {}, + }); + + await extension.startup(); + await extension.extension.promiseBackgroundStarted(); + const promiseTerminateBackground = extension.extension.terminateBackground(); + // Wait one tick to leave to terminateBackground async method time to get + // past the first check that returns earlier if extension.hasShutdown is true. + await Promise.resolve(); + const promiseUnload = extension.unload(); + + await promiseUnload; + try { + await promiseTerminateBackground; + ok(true, "extension.terminateBackground should not have been rejected"); + } catch (err) { + ok( + false, + `extension.terminateBackground should not have been rejected: ${err} :: ${err.stack}` + ); + } +} + +add_task(function test_mv2_suspend_shutdown_race() { + return testSuspendShutdownRace({ manifest_version: 2 }); +}); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + function test_mv3_suspend_shutdown_race() { + return testSuspendShutdownRace({ manifest_version: 3 }); + } +); + +function createPendingListenerTestExtension() { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings"], + background: { persistent: false }, + }, + background() { + let idx = 0; + browser.browserSettings.allowPopupsForUserEvents.onChange.addListener( + async () => { + const currIdx = idx++; + await new Promise((resolve, reject) => { + browser.test.onMessage.addListener(msg => { + switch (`${msg}-${currIdx}`) { + case "unblock-promise-0": + resolve(); + browser.test.sendMessage("allowPopupsForUserEvents:resolved"); + break; + case "unblock-promise-1": + reject(new Error("expected-test-rejection")); + browser.test.sendMessage("allowPopupsForUserEvents:rejected"); + break; + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + }); + browser.test.sendMessage("allowPopupsForUserEvents:awaiting"); + }); + } + ); + + browser.runtime.onSuspend.addListener(() => { + // Raise an error to test error handling in onSuspend + return browser.test.sendMessage("runtime-on-suspend"); + }); + + browser.test.sendMessage("bg-script-ready"); + }, + }); +} + +add_task( + { pref_set: [["extensions.background.idle.timeout", 500]] }, + async function test_eventpage_idle_reset_on_async_listener_unresolved() { + clearHistograms(); + + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + + let extension = createPendingListenerTestExtension(); + await extension.startup(); + await extension.awaitMessage("bg-script-ready"); + + info("Trigger the first API event listener call"); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + + await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); + + info("Trigger the second API event listener call"); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + + await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); + + info("Wait for suspend on idle to be reset"); + const [, resetIdleData] = await promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + + Assert.deepEqual( + resetIdleData, + { + reason: "pendingListeners", + pendingListeners: 2, + }, + "Got the expected idle reset reason and pendingListeners count" + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "reset_listeners", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: extension.id, + category: "reset_listeners", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); + + info( + "Resolve the async listener pending on a promise and expect the event page to suspend after the idle timeout" + ); + extension.sendMessage("unblock-promise"); + // Expect the two promises to be resolved and rejected respectively. + await extension.awaitMessage("allowPopupsForUserEvents:resolved"); + await extension.awaitMessage("allowPopupsForUserEvents:rejected"); + + info("Await for the runtime.onSuspend event to be emitted"); + await extension.awaitMessage("runtime-on-suspend"); + await extension.unload(); + } +); + +add_task( + { pref_set: [["extensions.background.idle.timeout", 500]] }, + async function test_pending_async_listeners_promises_rejected_on_shutdown() { + let extension = createPendingListenerTestExtension(); + await extension.startup(); + await extension.awaitMessage("bg-script-ready"); + + info("Trigger the API event listener call"); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + + await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); + + const { runListenerPromises } = extension.extension.backgroundContext; + equal( + runListenerPromises.size, + 1, + "Got the expected number of pending runListener promises" + ); + + const pendingPromise = Array.from(runListenerPromises)[0]; + + // Shutdown the extension while there is still a pending promises being tracked + // to verify they gets rejected as expected when the background page browser element + // is going to be destroyed. + await extension.unload(); + equal( + runListenerPromises.size, + 0, + "Expect no remaining pending runListener promises" + ); + + await Assert.rejects( + pendingPromise, + /Actor 'Conduits' destroyed before query 'RunListener' was resolved/, + "Previously pending runListener promise rejected with the expected error" + ); + } +); + +add_task( + { pref_set: [["extensions.background.idle.timeout", 500]] }, + async function test_eventpage_idle_reset_once_on_pending_async_listeners() { + let extension = createPendingListenerTestExtension(); + await extension.startup(); + await extension.awaitMessage("bg-script-ready"); + + info("Trigger the API event listener call"); + ExtensionPreferencesManager.setSetting( + extension.id, + "allowPopupsForUserEvents", + "click" + ); + + await extension.awaitMessage("allowPopupsForUserEvents:awaiting"); + + info("Wait for suspend on the first idle timeout to be reset"); + const [, resetIdleData] = await promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + + Assert.deepEqual( + resetIdleData, + { + reason: "pendingListeners", + pendingListeners: 1, + }, + "Got the expected idle reset reason and pendingListeners count" + ); + + info( + "Await for the runtime.onSuspend event to be emitted on the second idle timeout hit" + ); + // We expect this part of the test to trigger a uncaught rejection for the + // "Actor 'Conduits' destroyed before query 'RunListener' was resolved" error, + // due to the listener left purposely pending in this test + // and so that expected rejection is ignored using PromiseTestUtils in the preamble + // of this test file. + await extension.awaitMessage("runtime-on-suspend"); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js new file mode 100644 index 0000000000..66a6b45020 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_settings.js @@ -0,0 +1,166 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + +ChromeUtils.defineModuleGetter( + this, + "AboutNewTab", + "resource:///modules/AboutNewTab.jsm" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + // 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, + }; + + // 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); + } + }); +}); + +// Other tests exist for all the settings, this smoke tests that the +// settings will startup an event page. +add_task(async function test_browser_settings() { + let setExt = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings", "privacy"], + }, + background() { + browser.test.onMessage.addListener(async (msg, apiName, value) => { + let apiObj = apiName.split(".").reduce((o, i) => o[i], browser); + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + } + }); + }, + }); + await setExt.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["browserSettings", "privacy"], + background: { persistent: false }, + }, + background() { + browser.browserSettings.cacheEnabled.onChange.addListener(() => { + browser.test.log("cacheEnabled received"); + browser.test.sendMessage("cacheEnabled"); + }); + browser.browserSettings.homepageOverride.onChange.addListener(() => { + browser.test.sendMessage("homepageOverride"); + }); + browser.browserSettings.newTabPageOverride.onChange.addListener(() => { + browser.test.sendMessage("newTabPageOverride"); + }); + browser.privacy.services.passwordSavingEnabled.onChange.addListener( + () => { + browser.test.sendMessage("passwordSavingEnabled"); + } + ); + }, + }); + await extension.startup(); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + assertPersistentListeners(extension, "browserSettings", "cacheEnabled", { + primed: true, + }); + + info(`testing cacheEnabled`); + setExt.sendMessage("set", "browserSettings.cacheEnabled", false); + await extension.awaitMessage("cacheEnabled"); + ok(true, "cacheEnabled.onChange fired"); + + await extension.terminateBackground({ disableResetIdleForTest: true }); + assertPersistentListeners(extension, "browserSettings", "homepageOverride", { + primed: true, + }); + + info(`testing homepageOverride`); + Preferences.set("browser.startup.homepage", "http://homepage.example.com"); + await extension.awaitMessage("homepageOverride"); + ok(true, "homepageOverride.onChange fired"); + + if ( + AppConstants.platform !== "android" && + AppConstants.MOZ_APP_NAME !== "thunderbird" + ) { + await extension.terminateBackground({ disableResetIdleForTest: true }); + assertPersistentListeners( + extension, + "browserSettings", + "newTabPageOverride", + { + primed: true, + } + ); + + info(`testing newTabPageOverride`); + AboutNewTab.newTabURL = "http://homepage.example.com"; + await extension.awaitMessage("newTabPageOverride"); + ok(true, "newTabPageOverride.onChange fired"); + } + + await extension.terminateBackground({ disableResetIdleForTest: true }); + assertPersistentListeners( + extension, + "privacy", + "services.passwordSavingEnabled", + { + primed: true, + } + ); + + info(`testing passwordSavingEnabled`); + setExt.sendMessage("set", "privacy.services.passwordSavingEnabled", true); + await extension.awaitMessage("passwordSavingEnabled"); + ok(true, "passwordSavingEnabled.onChange fired"); + + await AddonTestUtils.promiseRestartManager(); + await setExt.awaitStartup(); + await extension.awaitStartup(); + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + + assertPersistentListeners(extension, "browserSettings", "homepageOverride", { + primed: true, + }); + + info(`testing homepageOverride after AOM restart`); + Preferences.set("browser.startup.homepage", "http://test.example.com"); + await extension.awaitMessage("homepageOverride"); + ok(true, "homepageOverride.onChange fired"); + + await extension.unload(); + await setExt.unload(); +}); 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..e7b798165f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js @@ -0,0 +1,98 @@ +"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( + { + // This test case covers expected warnings emitted when the + // event page support is disabled by prefs. + pref_set: [["extensions.eventPages.enabled", false]], + }, + 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..cc3cd33534 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js @@ -0,0 +1,377 @@ +"use strict"; + +/* globals browser */ +const { AddonSettings } = ChromeUtils.import( + "resource://gre/modules/addons/AddonSettings.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +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, + }); + + if (testCase.temporarilyInstalled && !testCase.shouldHaveExperiments) { + ExtensionTestUtils.failOnSchemaWarnings(false); + await Assert.rejects( + extension.startup(), + /Using 'experiment_apis' requires a privileged add-on/, + "startup failed without experimental api access" + ); + ExtensionTestUtils.failOnSchemaWarnings(true); + } else { + 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: { + browser_specific_settings: { + 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..b50d8cd734 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js @@ -0,0 +1,74 @@ +/* -*- 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() { + 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(); +}); + +add_task(async function test_is_denied_incognito_access() { + 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(); +}); + +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..2349b1d7cc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js @@ -0,0 +1,885 @@ +/* -*- 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.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); +var { PromiseUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); + +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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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..0fea0817ce --- /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: { + browser_specific_settings: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: "@second" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { 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: { + browser_specific_settings: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { 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..ee5eb84907 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.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 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"); + } + + 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_page_navigated.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js new file mode 100644 index 0000000000..402b6071a5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_page_navigated.js @@ -0,0 +1,339 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/", (request, response) => { + response.write(`<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <title>test webpage</title> + </head> + </html> + `); +}); + +function createTestExtPage({ script }) { + return `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="${script}"></script> + </head> + </html> + `; +} + +function createTestExtPageScript(name) { + return `(${async function(pageName) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log( + `${pageName} got a webRequest.onBeforeRequest event: ${details.url}` + ); + browser.test.sendMessage(`event-received:${pageName}`); + }, + { urls: ["http://example.com/request*"] } + ); + + // Calling an API implemented in the parent process to make sure + // the webRequest.onBeforeRequest listener is got registered in + // the parent process by the time the test is going to expect that + // listener to intercept a test web request. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage(`page-loaded:${pageName}`); + }})("${name}");`; +} + +const getExtensionContextIdAndURL = ([extensionId]) => { + const { ExtensionProcessScript } = ChromeUtils.import( + "resource://gre/modules/ExtensionProcessScript.jsm" + ); + let extWindow = this.content.window; + let extChild = ExtensionProcessScript.getExtensionChild(extensionId); + + let contextIds = []; + let contextURLs = []; + for (let ctx of extChild.views) { + if (ctx.contentWindow === extWindow) { + // Only one is expected, but we collect details from all + // the ones that match to make sure the test will fails + // in case there are unexpected multiple extension contexts + // associated to the same contentWindow. + contextIds.push(ctx.contextId); + contextURLs.push(ctx.contentWindow.location.href); + } + } + return { contextIds, contextURLs }; +}; + +const getExtensionContextStatusByContextId = ([ + extensionId, + extPageContextId, +]) => { + const { ExtensionProcessScript } = ChromeUtils.import( + "resource://gre/modules/ExtensionProcessScript.jsm" + ); + let extChild = ExtensionProcessScript.getExtensionChild(extensionId); + + let context; + for (let ctx of extChild.views) { + if (ctx.contextId === extPageContextId) { + context = ctx; + } + } + return context?.active; +}; + +add_task(async function test_extension_page_sameprocess_navigation() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "http://example.com/*"], + }, + files: { + "extpage1.html": createTestExtPage({ script: "extpage1.js" }), + "extpage1.js": createTestExtPageScript("extpage1"), + "extpage2.html": createTestExtPage({ script: "extpage2.js" }), + "extpage2.js": createTestExtPageScript("extpage2"), + }, + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + + const extPageURL1 = policy.extension.baseURI.resolve("extpage1.html"); + const extPageURL2 = policy.extension.baseURI.resolve("extpage2.html"); + + info("Opening extension page in a first browser element"); + const extPage = await ExtensionTestUtils.loadContentPage(extPageURL1); + await extension.awaitMessage("page-loaded:extpage1"); + + const { contextIds, contextURLs } = await extPage.spawn( + [extension.id], + getExtensionContextIdAndURL + ); + + Assert.deepEqual( + contextURLs, + [extPageURL1], + `Found an extension context with the expected page url` + ); + + ok( + contextIds[0], + `Found an extension context with contextId ${contextIds[0]}` + ); + ok( + contextIds.length, + `There should be only one extension context for a given content window, found ${contextIds.length}` + ); + + const [contextId] = contextIds; + + await ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/request1" + ); + await extension.awaitMessage("event-received:extpage1"); + + info("Load a second extension page in the same browser element"); + await extPage.loadURL(extPageURL2); + await extension.awaitMessage("page-loaded:extpage2"); + + let active; + + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + // We only expect extpage2 to be able to receive API events. + await ExtensionTestUtils.fetch( + "http://example.com", + "http://example.com/request2" + ); + await extension.awaitMessage("event-received:extpage2"); + + active = await extPage.spawn( + [extension.id, contextId], + getExtensionContextStatusByContextId + ); + }); + + if ( + Services.appinfo.fissionAutostart && + WebExtensionPolicy.isExtensionProcess + ) { + // When the extension are running in the main process while the webpages run + // in a separate child process, the extension page doesn't enter the BFCache + // because nsFrameLoader::changeRemotenessCommon bails out due to retainPaint + // being computed as true (see + // https://searchfox.org/mozilla-central/rev/24c1cdc33ccce692612276cd0d3e9a44f6c22fd3/dom/base/nsFrameLoaderOwner.cpp#185-196 + // ). + equal(active, undefined, "extension page context should not exist anymore"); + } else { + equal( + active, + false, + "extension page context is expected to be inactive while moved into the BFCache" + ); + } + + if (typeof active === "boolean") { + AddonTestUtils.checkMessages( + messages, + { + forbidden: [ + // We should not have tried to deserialize the event data for the extension page + // that got moved into the BFCache (See Bug 1499129). + { + message: /StructureCloneHolder.deserialize: Argument 1 is not an object/, + }, + ], + expected: [ + // If the extension page is expected to be in the BFCache, then we expect to see + // a warning message logged for the ignored listener. + { + message: /Ignored listener for inactive context .* path=webRequest.onBeforeRequest/, + }, + ], + }, + "Expect no StructureCloneHolder error due to trying to send the event to inactive context" + ); + } + + await extPage.close(); + await extension.unload(); +}); + +add_task(async function test_extension_page_context_navigated_to_web_page() { + const extension = ExtensionTestUtils.loadExtension({ + files: { + "extpage.html": createTestExtPage({ script: "extpage.js" }), + "extpage.js": function() { + dump("loaded extension page\n"); + window.addEventListener( + "pageshow", + () => { + browser.test.log("Extension page got a pageshow event"); + browser.test.sendMessage("extpage:pageshow"); + }, + { once: true } + ); + window.addEventListener( + "pagehide", + () => { + browser.test.log("Extension page got a pagehide event"); + browser.test.sendMessage("extpage:pagehide"); + }, + { once: true } + ); + }, + }, + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + + const extPageURL = policy.extension.baseURI.resolve("extpage.html"); + const webPageURL = "http://example.com/"; + + info("Opening extension page in a browser element"); + const extPage = await ExtensionTestUtils.loadContentPage(extPageURL); + await extension.awaitMessage("extpage:pageshow"); + + const { contextIds, contextURLs } = await extPage.spawn( + [extension.id], + getExtensionContextIdAndURL + ); + + Assert.deepEqual( + contextURLs, + [extPageURL], + `Found an extension context with the expected page url` + ); + + ok( + contextIds[0], + `Found an extension context with contextId ${contextIds[0]}` + ); + ok( + contextIds.length, + `There should be only one extension context for a given content window, found ${contextIds.length}` + ); + + const [contextId] = contextIds; + + info("Load a webpage in the same browser element"); + await extPage.loadURL(webPageURL); + await extension.awaitMessage("extpage:pagehide"); + + info("Open extension page in a second browser element"); + const extPage2 = await ExtensionTestUtils.loadContentPage(extPageURL); + await extension.awaitMessage("extpage:pageshow"); + + let active = await extPage2.spawn( + [extension.id, contextId], + getExtensionContextStatusByContextId + ); + + if (WebExtensionPolicy.isExtensionProcess) { + // When the extension are running in the main process while the webpages run + // in a separate child process, the extension page doesn't enter the BFCache + // because nsFrameLoader::changeRemotenessCommon bails out due to retainPaint + // being computed as true (see + // https://searchfox.org/mozilla-central/rev/24c1cdc33ccce692612276cd0d3e9a44f6c22fd3/dom/base/nsFrameLoaderOwner.cpp#185-196 + // ). + equal(active, undefined, "extension page context should not exist anymore"); + } else if (Services.appinfo.fissionAutostart) { + // When fission is enabled and the extensions runs in their own child extension + // process, the BFCache is managed entirely from the parent process and the + // extension page is expected to be able to enter the BFCache. + equal( + active, + false, + "extension page context is expected to be inactive while moved into the BFCache" + ); + } else { + // With the extension running in a separate child process but fission disabled, + // we expect the extension page to don't enter the BFCache. + equal(active, undefined, "extension page context should not exist anymore"); + } + + if (active === false) { + info( + "Navigating to more web pages to confirm the extension page have been evicted from the BFCache" + ); + for (let i = 2; i < 5; i++) { + const url = `${webPageURL}/page${i}`; + info(`Navigating to ${url}`); + await extPage.loadURL(url); + } + equal( + await extPage2.spawn( + [extension.id, contextId], + getExtensionContextStatusByContextId + ), + undefined, + "extension page context should have been evicted" + ); + } + + info("Cleanup and exit test"); + + await Promise.all([ + extPage.close(), + extPage2.close(), + extension.awaitMessage("extpage:pagehide"), + ]); + await extension.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..ef74c49cf9 --- /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: { + browser_specific_settings: { 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..36c9cd519d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.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"; + +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() { + 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..f0e5388577 --- /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"], + browser_specific_settings: { + 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..79e791b8c6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js @@ -0,0 +1,68 @@ +"use strict"; + +add_task(async function() { + // The startupCache is removed whenever the buildid changes by code that runs + // during Firefox startup but not during xpcshell startup, remove it by hand + // before running this test to avoid failures with --conditioned-profile + let file = PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "startupCache", + "webext.sc.lz4" + ); + await IOUtils.remove(file, { ignoreAbsent: true }); + + 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"], + browser_specific_settings: { + 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) { + // If this fails, check the lists in {,Base}ProfilerState.h and geckoProfiler.json. + ok( + acceptedFeatures.includes(feature), + `The schema of the geckoProfiler.start() method should accept the "${feature}" feature.` + ); + } + for (const feature of acceptedFeatures) { + // If this fails, check the lists in {,Base}ProfilerState.h and geckoProfiler.json. + 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..b9048787d5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js @@ -0,0 +1,64 @@ +"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]); + }, + }, + }); + // Turn off warning as errors to pass for deprecated APIs + ExtensionTestUtils.failOnSchemaWarnings(false); + 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(); + ExtensionTestUtils.failOnSchemaWarnings(true); +}); 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..bc2e30660f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js @@ -0,0 +1,571 @@ +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +// 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"); + + // TODO bug 1765375: ", en" is missing on Android. + let expectedLangs = + AppConstants.platform == "android" ? ["en-US"] : ["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() { + 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..9d45bfe323 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js @@ -0,0 +1,197 @@ +"use strict"; + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +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: { + browser_specific_settings: { + 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..6c1b523e05 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js @@ -0,0 +1,361 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +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(); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_idle_event_page() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["idle"], + background: { persistent: false }, + }, + background() { + browser.idle.setDetectionInterval(99); + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "active", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + }, + }); + + idleService._reset(); + await extension.startup(); + assertPersistentListeners(extension, "idle", "onStateChanged", { + primed: false, + }); + checkActivity({ + expectedAdd: [99], + expectedRemove: [], + expectedFires: [], + }); + + idleService._reset(); + await extension.terminateBackground(); + assertPersistentListeners(extension, "idle", "onStateChanged", { + primed: true, + }); + checkActivity({ + expectedAdd: [99], + expectedRemove: [99], + expectedFires: [], + }); + + // Fire an idle notification to wake up the background. + idleService._fireObservers("active"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [99], + expectedRemove: [99], + expectedFires: ["active"], + }); + + // Verify the set idle time is used with the persisted listener. + idleService._reset(); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + assertPersistentListeners(extension, "idle", "onStateChanged", { + primed: true, + }); + checkActivity({ + expectedAdd: [99], // 99 should have been persisted + expectedRemove: [99], // remove is from AOM shutdown + expectedFires: [], + }); + + // Fire an idle notification to wake up the background. + idleService._fireObservers("active"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [99], + expectedRemove: [99], + expectedFires: ["active"], + }); + + 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..b4b00e7db4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js @@ -0,0 +1,127 @@ +/* -*- 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(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +async function runIncognitoTest(extensionData, privateBrowsingAllowed) { + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + let { extension } = wrapper; + + 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(); +} + +add_task(async function test_extension_incognito_spanning() { + await runIncognitoTest({}, false); +}); + +// 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); +}); + +// 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); +}); + +add_task(async function test_extension_privileged_not_allowed() { + let addonId = "privileged_not_allowed@mochi.test"; + let extensionData = { + manifest: { + version: "1.0", + browser_specific_settings: { 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() { + let addonId = "upgrade@mochi.test"; + let extensionData = { + manifest: { + version: "1.0", + browser_specific_settings: { 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..aba25173d7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.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"; + +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. + "manifest_version": 2, + "browser_specific_settings": {"gecko": {"id": "${ID}"}}, + "name": "This \" is // not a comment", + "version": "0.1\\" // , "description": "This is not a description" + }`, + }, + }); + + let expectedManifest = { + manifest_version: 2, + browser_specific_settings: { 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, false); + + await extension.parseManifest(); + + Assert.deepEqual( + extension.rawManifest, + expectedManifest, + "Manifest with correctly-filtered comments" + ); + + Services.obs.notifyObservers(xpi, "flush-cache-entry"); +}); + +add_task(async function test_getExtensionVersionWithoutValidation() { + let xpi = AddonTestUtils.createTempWebExtensionFile({ + files: { + "manifest.json": String.raw`{ + // This is valid JSON but not a valid manifest. + "version": ["This is not a valid version"] + }`, + }, + }); + let fileURI = Services.io.newFileURI(xpi); + let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`); + let extension = new ExtensionData(uri, false); + + let rawVersion = await extension.getExtensionVersionWithoutValidation(); + Assert.deepEqual( + rawVersion, + ["This is not a valid version"], + "Got the raw value of the 'version' key from an (invalid) manifest file" + ); + + // The manifest lacks several required properties and manifest_version is + // invalid. The exact error here doesn't matter, as long as it shows that the + // manifest is invalid. + await Assert.rejects( + extension.parseManifest(), + /Unexpected params.manifestVersion value: undefined/, + "parseManifest() should reject an invalid manifest" + ); + + Services.obs.notifyObservers(xpi, "flush-cache-entry"); +}); + +add_task( + { + pref_set: [ + ["extensions.manifestV3.enabled", true], + ["extensions.webextensions.warnings-as-errors", false], + ], + }, + async function test_applications_no_longer_valid_in_mv3() { + let id = "some@id"; + let xpi = AddonTestUtils.createTempWebExtensionFile({ + files: { + "manifest.json": JSON.stringify({ + manifest_version: 3, + name: "some name", + version: "0.1", + applications: { gecko: { id } }, + }), + }, + }); + + let fileURI = Services.io.newFileURI(xpi); + let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(uri, false); + + const { manifest } = await extension.parseManifest(); + ok( + !Object.keys(manifest).includes("applications"), + "expected no applications key in manifest" + ); + + 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..75081a64f9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js @@ -0,0 +1,166 @@ +"use strict"; + +const { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +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 L10nFileSource( + "test", + "app", + Services.locale.requestedLocales, + "resource://l10ntest/" + ); + L10nRegistry.getInstance().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 unprivileged 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 = false, + temporarilyInstalled = false, + } = {}) { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged, + temporarilyInstalled, + manifest: { + l10n_resources: ["test.ftl"], + page_action: { + default_title: "__MSG_key__", + }, + }, + }); + + if (temporarilyInstalled && !isPrivileged) { + ExtensionTestUtils.failOnSchemaWarnings(false); + await Assert.rejects( + extension.startup(), + /Using 'l10n_resources' requires a privileged add-on/, + "startup failed without privileged api access" + ); + ExtensionTestUtils.failOnSchemaWarnings(true); + return; + } + await extension.startup(); + let title = extension.extension.manifest.page_action.default_title; + await extension.unload(); + return title; + } + + let title = await runTest({ isPrivileged: true }); + equal( + title, + "value", + "Manifest key localized with fluent in privileged extension" + ); + + title = await runTest(); + equal( + title, + "__MSG_key__", + "Manifest key not localized in unprivileged extension" + ); + + title = await runTest({ temporarilyInstalled: true }); + equal(title, undefined, "Startup fails with temporarilyInstalled 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..9adb549afe --- /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: { browser_specific_settings: { 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..ae5fc14ba7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management.js @@ -0,0 +1,339 @@ +/* -*- 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(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +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: { + browser_specific_settings: { + 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: { + browser_specific_settings: { + 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 { + browser_specific_settings: { + 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(); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_management_event_page() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["management"], + background: { persistent: false }, + }, + background() { + browser.management.onInstalled.addListener(details => { + browser.test.sendMessage("onInstalled", details); + }); + browser.management.onUninstalled.addListener(details => { + browser.test.sendMessage("onUninstalled", details); + }); + browser.management.onEnabled.addListener(() => { + browser.test.sendMessage("onEnabled"); + }); + browser.management.onDisabled.addListener(() => { + browser.test.sendMessage("onDisabled"); + }); + }, + }); + + await extension.startup(); + let events = ["onInstalled", "onUninstalled", "onEnabled", "onDisabled"]; + for (let event of events) { + assertPersistentListeners(extension, "management", event, { + primed: false, + }); + } + + await extension.terminateBackground(); + for (let event of events) { + assertPersistentListeners(extension, "management", event, { + primed: true, + }); + } + + let testExt = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "test-ext@mochitest" } }, + }, + background() {}, + }); + await testExt.startup(); + + let details = await extension.awaitMessage("onInstalled"); + equal(testExt.id, details.id, "got onInstalled event"); + + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + await testExt.awaitStartup(); + + for (let event of events) { + assertPersistentListeners(extension, "management", event, { + primed: true, + }); + } + + // Test uninstalling an addon wakes up the watching extension. + let uninstalled = testExt.unload(); + + details = await extension.awaitMessage("onUninstalled"); + equal(testExt.id, details.id, "got onUninstalled event"); + + await extension.unload(); + await uninstalled; + } +); + +// Sanity check that Addon listeners are removed on context close. +add_task( + { + // __AddonManagerInternal__ is exposed for debug builds only. + skip_if: () => !AppConstants.DEBUG, + }, + async function test_management_unregister_listener() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["management"], + }, + files: { + "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`, + "extpage.js": function() { + browser.management.onInstalled.addListener(() => {}); + }, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/extpage.html` + ); + + const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" + ); + function assertManagementAPIAddonListener(expect) { + let found = false; + for (const addonListener of AddonManager.__AddonManagerInternal__ + ?.addonListeners || []) { + if ( + Object.getPrototypeOf(addonListener).constructor.name === + "ManagementAddonListener" + ) { + found = true; + } + } + equal( + found, + expect, + `${ + expect ? "Should" : "Should not" + } have found an AOM addonListener registered by the management API` + ); + } + + assertManagementAPIAddonListener(true); + await page.close(); + assertManagementAPIAddonListener(false); + await extension.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..75c3469588 --- /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.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +const id = "uninstall_self_test@tests.mozilla.com"; + +const manifest = { + browser_specific_settings: { + 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/prompter;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..8ae3909a2c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js @@ -0,0 +1,280 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +async function testManifest(manifest, expectedError) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let normalized = await ExtensionTestUtils.normalizeManifest(manifest); + ExtensionTestUtils.failOnSchemaWarnings(true); + + if (expectedError) { + ok( + expectedError.test(normalized.error), + `Should have an error for ${JSON.stringify(manifest)}, got ${ + normalized.error + }` + ); + } else { + ok( + !normalized.error, + `Should not have an error ${JSON.stringify(manifest)}, ${ + normalized.error + }` + ); + } + return normalized.errors; +} + +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_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +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(); +}); + +add_task(async function test_mv2_scripting_permission_always_enabled() { + let warnings = await testManifest({ + manifest_version: 2, + permissions: ["scripting"], + }); + + Assert.deepEqual(warnings, [], "Got no warnings"); +}); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_mv3_scripting_permission_always_enabled() { + let warnings = await testManifest({ + manifest_version: 3, + permissions: ["scripting"], + }); + + Assert.deepEqual(warnings, [], "Got no warnings"); + } +); + +add_task(async function test_simpler_version_format() { + const TEST_CASES = [ + // Valid cases + { version: "0", expectWarning: false }, + { version: "0.0", expectWarning: false }, + { version: "0.0.0", expectWarning: false }, + { version: "0.0.0.0", expectWarning: false }, + { version: "0.0.0.1", expectWarning: false }, + { version: "0.0.0.999999999", expectWarning: false }, + { version: "0.0.1.0", expectWarning: false }, + { version: "0.0.999999999", expectWarning: false }, + { version: "0.1.0.0", expectWarning: false }, + { version: "0.999999999", expectWarning: false }, + { version: "1", expectWarning: false }, + { version: "1.0", expectWarning: false }, + { version: "1.0.0", expectWarning: false }, + { version: "1.0.0.0", expectWarning: false }, + { version: "1.2.3.4", expectWarning: false }, + { version: "999999999", expectWarning: false }, + { + version: "999999999.999999999.999999999.999999999", + expectWarning: false, + }, + // Invalid cases + { version: ".", expectWarning: true }, + { version: ".999999999", expectWarning: true }, + { version: "0.0.0.0.0", expectWarning: true }, + { version: "0.0.0.00001", expectWarning: true }, + { version: "0.0.0.0010", expectWarning: true }, + { version: "0.0.00001", expectWarning: true }, + { version: "0.0.001", expectWarning: true }, + { version: "0.0.01.0", expectWarning: true }, + { version: "0.01.0", expectWarning: true }, + { version: "00001", expectWarning: true }, + { version: "0001", expectWarning: true }, + { version: "001", expectWarning: true }, + { version: "01", expectWarning: true }, + { version: "01.0", expectWarning: true }, + { version: "099999", expectWarning: true }, + { version: "0999999999", expectWarning: true }, + { version: "1.00000", expectWarning: true }, + { version: "1.1.-1", expectWarning: true }, + { version: "1.1000000000", expectWarning: true }, + { version: "1.1pre1aa", expectWarning: true }, + { version: "1.2.1000000000", expectWarning: true }, + { version: "1.2.3.4-a", expectWarning: true }, + { version: "1.2.3.4.5", expectWarning: true }, + { version: "1000000000", expectWarning: true }, + { version: "1000000000.0.0.0", expectWarning: true }, + { version: "999999999.", expectWarning: true }, + ]; + + for (const { version, expectWarning } of TEST_CASES) { + const normalized = await ExtensionTestUtils.normalizeManifest({ version }); + + if (expectWarning) { + Assert.deepEqual( + normalized.errors, + [ + `Warning processing version: version must be a version string ` + + `consisting of at most 4 integers of at most 9 digits without ` + + `leading zeros, and separated with dots`, + ], + `expected warning for version: ${version}` + ); + } else { + Assert.deepEqual( + normalized.errors, + [], + `expected no warning for version: ${version}` + ); + } + } +}); + +add_task(async function test_applications() { + const id = "some@id"; + const updateURL = "https://example.com/updates/"; + + let extension = await ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + applications: { + gecko: { id, update_url: updateURL }, + }, + }, + useAddonManager: "temporary", + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.deepEqual(extension.extension.warnings, [], "expected no warnings"); + + const addon = await AddonManager.getAddonByID(extension.id); + ok(addon, "got an add-on"); + equal(addon.id, id, "got expected ID"); + equal(addon.updateURL, updateURL, "got expected update URL"); + + await extension.unload(); +}); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_applications_key_mv3() { + let warnings = await testManifest({ + manifest_version: 3, + applications: {}, + }); + + Assert.deepEqual( + warnings, + [`Property "applications" is unsupported in Manifest Version 3`], + `Manifest v3 with "applications" key logs an error.` + ); + } +); 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..a6e3f91a6b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js @@ -0,0 +1,114 @@ +/* -*- 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" + ); + + ExtensionTestUtils.failOnSchemaWarnings(false); + normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 2, + content_security_policy: { + extension_pages: "script-src 'self'; object-src 'none'", + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.deepEqual( + normalized.errors, + [ + `Error processing content_security_policy: Expected string instead of {"extension_pages":"script-src 'self'; object-src 'none'"}`, + ], + "Should have the expected warning" + ); +}); + +add_task(async function test_manifest_csp_v3() { + ExtensionTestUtils.failOnSchemaWarnings(false); + let normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + content_security_policy: "script-src 'self'; object-src 'none'", + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.deepEqual( + normalized.errors, + [ + `Error processing content_security_policy: Expected object instead of "script-src 'self'; object-src 'none'"`, + ], + "Should have the expected warning" + ); + + 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..4330e1b681 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.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"; + +add_task(async function test_manifest_incognito() { + 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" + ); +}); 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..add933cc46 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js @@ -0,0 +1,280 @@ +"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; + +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-script-event", "start-background-script"]) { + 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({ earlyStartup: false }); + await extension.awaitStartup(); + + function awaitBgEvent() { + return new Promise(resolve => + extension.extension.once("background-script-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-script-event"), + true, + "Should have gotten a background page event" + ); + equal( + events.get("start-background-script"), + 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"); + AddonTestUtils.notifyEarlyStartup(); + await promise; + + equal( + events.get("start-background-script"), + true, + "Should have gotten start-background-script 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`); + await promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + + events = trackEvents(extension); + + [, page] = await Promise.all([ + awaitBgEvent(), + ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ), + ]); + + equal( + events.get("background-script-event"), + true, + "Should have gotten a background script event" + ); + equal( + events.get("start-background-script"), + false, + "Background script should not be started" + ); + + equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message"); + + promise = extension.awaitMessage("bg-ran"); + AddonTestUtils.notifyEarlyStartup(); + await promise; + + equal( + events.get("start-background-script"), + true, + "Should have gotten start-background-script 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(); +} + +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({ lateStartup: false }); + await extension.awaitStartup(); + let events = trackEvents(extension); + + equal( + events.get("background-script-event"), + false, + "Should not have gotten a background page event" + ); + equal( + events.get("start-background-script"), + false, + "Background page should not be started" + ); + + // Start the background page. No message have been sent at this point. + await AddonTestUtils.notifyLateStartup(); + equal( + events.get("start-background-script"), + true, + "Background page should be started" + ); + + 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(); +}); 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..3b96418dd2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js @@ -0,0 +1,1051 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals chrome */ + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = + "webextensions.native-messaging.max-output-message-bytes"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +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 DELAYED_ECHO_BODY = String.raw` + import atexit + import json + import os + import struct + import sys + import time + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + pid = os.getpid() + + sys.stderr.write("nativeapp with pid %d is running\n" % pid) + + def onexit(): + sys.stderr.write("nativeapp with pid %d is exiting\n" % pid) + + atexit.register(onexit) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + sys.stderr.write( + "nativeapp with pid %d delaying echoing message '%s'\n" % + (pid, str(msg, 'utf-8')) + ) + + time.sleep(5) + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) + + sys.stderr.write( + "nativeapp with pid %d replied to message '%s'\n" % + (pid, str(msg, 'utf-8')) + ) +`; + +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: "relative.echo", + description: "a native app that echoes; relative path instead of absolute", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + manifest.path = PathUtils.filename(manifest.path); + }, + }, + { + name: "renamed.echo", + description: "invalid manifest due to name mismatch", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + manifest.name = "renamed_name_mismatch"; + }, + }, + { + name: "nonstdio.echo", + description: "invalid manifest due to non-stdio type", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // schema only permits "stdio" or "pkcs11". Change from "stdio": + manifest.type = "pkcs11"; + }, + }, + { + name: "forwardslash.echo", + description: "a native app that echos; with forward slash in path", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + _hookModifyManifest(manifest) { + // On Linux/macOS, this doesn't change anything. + // On Windows, this turns C:\Program Files\... in C:/Program Files/... + manifest.path = manifest.path.replaceAll("\\", "/"); + }, + }, + { + name: "delayedecho", + description: + "a native app that echo messages received with a small artificial delay", + script: DELAYED_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_setup(async function setup() { + optionalPermissionsPromptHandler.init(); + optionalPermissionsPromptHandler.acceptPrompt = true; + await AddonTestUtils.promiseStartupManager(); + + 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() { + async function background() { + let port; + browser.test.onMessage.addListener(async (what, payload) => { + if (what == "request") { + await browser.permissions.request({ permissions: ["nativeMessaging"] }); + // connectNative requires permission + port = browser.runtime.connectNative("echo"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("message", msg); + }); + browser.test.sendMessage("ready"); + } else if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + optional_permissions: ["nativeMessaging"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + 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: { + browser_specific_settings: { 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; +} + +async function testBrokenApp({ + extensionId = ID, + appname, + expectedError, + expectedConsoleMessages, +}) { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(async (appname, expectedError) => { + await browser.test.assertRejects( + browser.runtime.sendNativeMessage(appname, "dummymsg"), + expectedError, + "Expected sendNativeMessage error" + ); + browser.test.sendMessage("done"); + }); + }, + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + let { messages } = await promiseConsoleOutput(async () => { + extension.sendMessage(appname, expectedError); + await extension.awaitMessage("done"); + }); + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); + + // Because we're using forbidUnexpected:true below, we have to account for + // all logged messages. RemoteSettings may try (and fail) to load remote + // settings - ignore the "NetworkError: Network request failed" error. + // To avoid having to update this filter all the time, select the specific + // modules relevant to native messaging from where we expect errors. + messages = messages.filter(m => { + return /NativeMessaging|NativeManifests|Subprocess/.test(m.message); + }); + + // On Linux/macOS, the setupHosts helper registers the same manifest file in + // multiple locations, which can result in the same error being printed + // multiple times. We de-duplicate that here. + let deduplicatedMessages = messages.filter( + (msg, i) => i === messages.findIndex(m => m.message === msg.message) + ); + + // Now check that all the log messages exist, in the expected order too. + AddonTestUtils.checkMessages( + deduplicatedMessages, + { + expected: expectedConsoleMessages.map(message => ({ message })), + forbidUnexpected: true, + }, + "Expected messages in the console" + ); +} + +if (AppConstants.platform == "win") { + // "relative.echo" has a relative path in the host manifest. + add_task(function test_relative_path() { + // Note: relative paths only supported on Windows. + // For non-Windows, see test_relative_path_unsupported instead. + return simpleTest("relative.echo"); + }); + + // "echocmd" uses a .cmd file instead of a .bat file + add_task(function test_cmd_file() { + return simpleTest("echocmd"); + }); +} else { + // On non-Windows, relative paths are not supported. + add_task(function test_relative_path_unsupported() { + return testBrokenApp({ + appname: "relative.echo", + expectedError: "An unexpected error occurred", + expectedConsoleMessages: [ + /File at path "relative\.echo\.py" does not exist, or is not executable/, + ], + }); + }); +} + +add_task(async function test_error_name_mismatch() { + await testBrokenApp({ + appname: "renamed.echo", + expectedError: "No such native application renamed.echo", + expectedConsoleMessages: [ + /Native manifest .+ has name property renamed_name_mismatch \(expected renamed\.echo\)/, + /No such native application renamed\.echo/, + ], + }); +}); + +add_task(async function test_invalid_manifest_type_not_stdio() { + await testBrokenApp({ + appname: "nonstdio.echo", + expectedError: "No such native application nonstdio.echo", + expectedConsoleMessages: [ + /Native manifest .+ has type property pkcs11 \(expected stdio\)/, + /No such native application nonstdio\.echo/, + ], + }); +}); + +add_task(async function test_forward_slashes_in_path_works() { + await simpleTest("forwardslash.echo"); +}); + +// 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), + "No such native application nonexistent", + "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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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() { + await testBrokenApp({ + extensionId: "@id-that-is-not-in-the-allowed_extensions-list", + appname: "echo", + expectedError: "No such native application echo", + expectedConsoleMessages: [ + /This extension does not have permission to use native manifest .+echo\.json/, + /No such native application echo/, + ], + }); +}); + +// 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: { + browser_specific_settings: { 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\//, "/"), + PathUtils.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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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"], + }, + ], + browser_specific_settings: { 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"); +}); + +// Testing native app messaging against idle timeout. +async function startupExtensionAndRequestPermission() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + optional_permissions: ["nativeMessaging"], + background: { persistent: false }, + }, + async background() { + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("bgpage:suspending"); + }); + + let port; + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "request-permission": { + await browser.permissions.request({ + permissions: ["nativeMessaging"], + }); + break; + } + case "delayedecho-sendmessage": { + browser.runtime + .sendNativeMessage("delayedecho", args[0]) + .then(msg => + browser.test.sendMessage( + `delayedecho-sendmessage:got-reply`, + msg + ) + ); + break; + } + case "connectNative": { + if (port) { + browser.test.fail(`Unexpected already connected NativeApp port`); + } else { + port = browser.runtime.connectNative("echo"); + } + break; + } + case "disconnectNative": { + if (!port) { + browser.test.fail(`Unexpected undefined NativeApp port`); + } + port?.disconnect(); + break; + } + default: + browser.test.fail(`Got an unexpected test message: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + browser.test.sendMessage("bg:ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("bg:ready"); + const contextId = extension.extension.backgroundContext.contextId; + notEqual(contextId, undefined, "Got a contextId for the background context"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request-permission"); + await extension.awaitMessage("request-permission:done"); + }); + + return { extension, contextId }; +} + +async function expectTerminateBackgroundToResetIdle({ extension, contextId }) { + info("Wait for hasActiveNativeAppPorts to become true"); + await TestUtils.waitForCondition( + () => extension.extension.backgroundContext, + "Parent proxy context should be active" + ); + + await TestUtils.waitForCondition( + () => extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should have active native app ports tracked" + ); + + clearHistograms(); + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + + info("Trigger background script idle timeout and expect to be reset"); + const promiseResetIdle = promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + await extension.terminateBackground(); + info("Wait for 'background-script-reset-idle' event to be emitted"); + await promiseResetIdle; + equal( + extension.extension.backgroundContext.contextId, + contextId, + "Initial background context is still available as expected" + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "reset_nativeapp", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: extension.id, + category: "reset_nativeapp", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); +} + +async function testSendNativeMessage({ extension, contextId }) { + extension.sendMessage("delayedecho-sendmessage", "delayed-echo"); + await extension.awaitMessage("delayedecho-sendmessage:done"); + + await expectTerminateBackgroundToResetIdle({ extension, contextId }); + + // We expect exactly two replies (one for the previous queued message + // and one more for the last message sent right above). + equal( + await extension.awaitMessage("delayedecho-sendmessage:got-reply"), + "delayed-echo", + "Got the expected reply for the first message sent" + ); + + await TestUtils.waitForCondition( + () => !extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should not have any active native app ports tracked" + ); + + info("terminating the background script"); + await extension.terminateBackground(); + info("wait for runtime.onSuspend listener to have been called"); + await extension.awaitMessage("bgpage:suspending"); +} + +async function testConnectNative({ extension, contextId }) { + extension.sendMessage("connectNative"); + await extension.awaitMessage("connectNative:done"); + + await expectTerminateBackgroundToResetIdle({ extension, contextId }); + + // Disconnect the NativeApp and confirm that the background page + // will be suspending as expected. + extension.sendMessage("disconnectNative"); + await extension.awaitMessage("disconnectNative:done"); + + await TestUtils.waitForCondition( + () => !extension.extension.backgroundContext?.hasActiveNativeAppPorts, + "Parent proxy context should not have any active native app ports tracked" + ); + + info("terminating the background script"); + await extension.terminateBackground(); + info("wait for runtime.onSuspend listener to have been called"); + await extension.awaitMessage("bgpage:suspending"); +} + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_pending_sendNativeMessageReply_resets_bgscript_idle_timeout() { + const { + extension, + contextId, + } = await startupExtensionAndRequestPermission(); + await testSendNativeMessage({ extension, contextId }); + await waitForSubprocessExit(); + await extension.unload(); + } +); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_open_connectNativePort_resets_bgscript_idle_timeout() { + const { + extension, + contextId, + } = await startupExtensionAndRequestPermission(); + await testConnectNative({ extension, contextId }); + await waitForSubprocessExit(); + await extension.unload(); + } +); 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..7c5d09dc39 --- /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: { + browser_specific_settings: { 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..5b30a06a23 --- /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: { + browser_specific_settings: { 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..1bdbe25491 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js @@ -0,0 +1,208 @@ +"use strict"; + +const Cm = Components.manager; + +const uuidGenerator = Services.uuid; + +AddonTestUtils.init(this); + +var mockNetworkStatusService = { + contractId: "@mozilla.org/network/network-link-service;1", + + _mockClassId: uuidGenerator.generateUUID(), + + _originalClassId: "", + + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + + createInstance(iiD) { + 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: { + browser_specific_settings: { + 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( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + async function test_networkStatus_permission() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + isPrivileged: false, + manifest: { + browser_specific_settings: { + gecko: { id: "networkstatus-permission@tests.mozilla.org" }, + }, + permissions: ["networkStatus"], + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: /Using the privileged permission 'networkStatus' requires a privileged add-on/, + }, + ], + }, + true + ); + } +); 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..fda60c3a82 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js @@ -0,0 +1,105 @@ +/* -*- 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(iid) { + 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..244cacf9c7 --- /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.runtime.getURL("beasts/frog.html"); + browser.runtime.getURL("beasts/frog2.html"); + browser.runtime.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..3c859da747 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js @@ -0,0 +1,855 @@ +"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 { + // For Android, these strings are only used in tests. In the actual UI, the + // warnings are in Android-Components, as explained in bug 1671453. + bundle = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); +} +const DUMMY_APP_NAME = "Dummy brandName"; + +// nativeMessaging is in PRIVILEGED_PERMS on Android. +const IS_NATIVE_MESSAGING_PRIVILEGED = AppConstants.platform == "android"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +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); + let result = extension.manifestPermissions; + + if (extension.manifest.manifest_version >= 3) { + // In MV3, host permissions are optional by default. + deepEqual(result.origins, [], "No origins by default in MV3"); + let optional = extension.manifestOptionalPermissions; + deepEqual(optional.permissions, [], "No tests use optional_permissions"); + result.origins = optional.origins; + } + + await extension.cleanupGeneratedFile(); + return result; +} + +function getPermissionWarnings( + manifestPermissions, + options, + stringBundle = bundle +) { + let info = { + permissions: manifestPermissions, + appName: DUMMY_APP_NAME, + }; + let { msgs } = ExtensionData.formatPermissionStrings( + info, + stringBundle, + 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 callers of ExtensionData.formatPermissionStrings can customize the +// mapping between the permission names and related localized strings. +add_task(async function customized_permission_keys_mapping() { + const mockBundle = { + // Mocked nsIStringBundle getStringFromName to returns a fake localized string. + GetStringFromName: key => `Fake localized ${key}`, + formatStringFromName: (name, params) => "Fake formatted string", + }; + + // Define a non-default mapping for permission names -> locale keys. + const getKeyForPermission = perm => `customWebExtPerms.description.${perm}`; + + const manifest = { + permissions: ["downloads", "proxy"], + }; + const expectedWarnings = manifest.permissions.map(k => + mockBundle.GetStringFromName(getKeyForPermission(k)) + ); + const manifestPermissions = await getManifestPermissions({ manifest }); + + // Pass the callback function for the non-default key mapping to + // ExtensionData.formatPermissionStrings() and verify it being used. + const warnings = getPermissionWarnings( + manifestPermissions, + { getKeyForPermission }, + mockBundle + ); + deepEqual( + warnings, + expectedWarnings, + "Got the expected string from customized permission mapping" + ); +}); + +// 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 manifest_version of [2, 3]) { + for (let { + description, + manifest, + expectedOrigins, + expectedWarnings, + options, + } of permissionTestCases) { + manifest = Object.assign({}, manifest, { manifest_version }); + if (manifest_version > 2) { + manifest.host_permissions = manifest.permissions; + manifest.permissions = []; + } + + 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({ + isPrivileged: IS_NATIVE_MESSAGING_PRIVILEGED, + 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" + ); +}); + +add_task(async function nativeMessaging_permission() { + let manifestPermissions = await getManifestPermissions({ + // isPrivileged: false, by default. + manifest: { + permissions: ["nativeMessaging"], + }, + }); + + if (IS_NATIVE_MESSAGING_PRIVILEGED) { + // The behavior of nativeMessaging for unprivileged extensions on Android + // is covered in + // mobile/android/components/extensions/test/xpcshell/test_ext_native_messaging_permissions.js + deepEqual( + manifestPermissions, + { origins: [], permissions: [] }, + "nativeMessaging perm ignored for unprivileged extensions on Android" + ); + } else { + deepEqual( + manifestPermissions, + { origins: [], permissions: ["nativeMessaging"] }, + "nativeMessaging permission recognized for unprivileged extensions" + ); + } +}); + +add_task(async function declarativeNetRequest_unavailable_by_default() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + }); + deepEqual( + manifestPermissions, + { origins: [], permissions: [] }, + "Expected declarativeNetRequest permission to be ignored/stripped" + ); +}); + +add_task( + { pref_set: [["extensions.dnr.enabled", true]] }, + async function declarativeNetRequest_permission_with_warning() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequest"], + }, + }); + + deepEqual( + manifestPermissions, + { origins: [], permissions: ["declarativeNetRequest"] }, + "Expected origins and permissions" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [ + bundle.GetStringFromName( + "webextPerms.description.declarativeNetRequest" + ), + ], + "Expected warnings" + ); + } +); + +add_task( + { pref_set: [["extensions.dnr.enabled", true]] }, + async function declarativeNetRequest_permission_without_warning() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + manifest_version: 3, + permissions: ["declarativeNetRequestWithHostAccess"], + }, + }); + + deepEqual( + manifestPermissions, + { origins: [], permissions: ["declarativeNetRequestWithHostAccess"] }, + "Expected origins and permissions" + ); + + deepEqual(getPermissionWarnings(manifestPermissions), [], "No 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 +// are not emitted for privileged extensions, only for unprivileged extensions. +add_task( + async function test_invalid_permission_warning_on_privileged_permission() { + await AddonTestUtils.promiseStartupManager(); + + const MANIFEST_WARNINGS = [ + "Reading manifest: Invalid extension permission: mozillaAddons", + "Reading manifest: Invalid extension permission: resource://x/", + "Reading manifest: Invalid extension permission: about:reader*", + ]; + + async function testInvalidPermissionWarning({ isPrivileged }) { + let id = isPrivileged + ? "privileged-addon@mochi.test" + : "nonprivileged-addon@mochi.test"; + + let expectedWarnings = isPrivileged ? [] : MANIFEST_WARNINGS; + + const ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["mozillaAddons", "resource://x/", "about:reader*"], + browser_specific_settings: { 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", "resource://x/", "about:reader*"], + browser_specific_settings: { + gecko: { id: "extension-data@mochi.test" }, + }, + }, + }); + + // Verify that XPIInstall.jsm will not collect the warning for the + // privileged permission as expected. + async function getWarningsFromExtensionData({ isPrivileged }) { + let extData; + if (typeof isPrivileged == "function") { + // isPrivileged expected to be computed asynchronously. + extData = await ExtensionData.constructAsync({ + rootURI: generatedExt.rootURI, + checkPrivileged: isPrivileged, + }); + } else { + extData = new ExtensionData(generatedExt.rootURI, isPrivileged); + } + await extData.loadManifest(); + + // 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" + ); + return extData.warnings; + } + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: undefined }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions by default" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: false }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions for non-privileged extensions" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: true }), + [], + "No warnings about privileged permissions on privileged extensions" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: async () => false }), + MANIFEST_WARNINGS, + "Got warnings about privileged permissions for non-privileged extensions (async)" + ); + + Assert.deepEqual( + await getWarningsFromExtensionData({ isPrivileged: async () => true }), + [], + "No warnings about privileged permissions on privileged extensions (async)" + ); + + // 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..88afd36dcc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js @@ -0,0 +1,240 @@ +"use strict"; + +// This file tests the behavior of fetch/XMLHttpRequest in content scripts, in +// relation to permissions, in MV2. +// In MV3, the expectations are different, test coverage for that is in +// test_ext_xhr_cors.js (along with CORS tests that also apply to MV2). + +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..359ad96773 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js @@ -0,0 +1,1003 @@ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +// 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" +); + +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(); + + optionalPermissionsPromptHandler.init(); + + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; +}); + +add_task(async function test_permissions_on_startup() { + let extensionId = "@permissionTest"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + 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(); +}); + +async function test_permissions({ + manifest_version, + granted_host_permissions, + useAddonManager, + expectAllGranted, +}) { + const REQUIRED_PERMISSIONS = ["downloads"]; + const REQUIRED_ORIGINS = ["*://site.com/", "*://*.domain.com/"]; + const REQUIRED_ORIGINS_EXPECTED = expectAllGranted + ? ["*://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.runtime.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: { + manifest_version, + permissions: REQUIRED_PERMISSIONS, + host_permissions: REQUIRED_ORIGINS, + optional_permissions: [...OPTIONAL_PERMISSIONS, ...OPTIONAL_ORIGINS], + granted_host_permissions, + }, + useAddonManager, + }); + + 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_EXPECTED); + + 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, + expectAllGranted, + `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. + optionalPermissionsPromptHandler.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 + optionalPermissionsPromptHandler.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_EXPECTED, ...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 extension, verify permissions are still present. + if (useAddonManager === "permanent") { + await AddonTestUtils.promiseRestartManager(); + } else { + // Manually reload for temporarily loaded. + await extension.addon.reload(); + } + await extension.awaitBackgroundStarted(); + + 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_EXPECTED, ...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_EXPECTED; + result = await call("getAll"); + deepEqual(result, perms, "Back to default permissions after removing more"); + + await extension.unload(); +} + +add_task(function test_normal_mv2() { + return test_permissions({ + manifest_version: 2, + useAddonManager: "permanent", + expectAllGranted: true, + }); +}); + +add_task(function test_normal_mv3() { + return test_permissions({ + manifest_version: 3, + useAddonManager: "permanent", + expectAllGranted: false, + }); +}); + +add_task(function test_granted_for_temporary_mv3() { + return test_permissions({ + manifest_version: 3, + granted_host_permissions: true, + useAddonManager: "temporary", + expectAllGranted: true, + }); +}); + +add_task(async function test_granted_only_for_privileged_mv3() { + try { + // For permanent non-privileged, granted_host_permissions does nothing. + await test_permissions({ + manifest_version: 3, + granted_host_permissions: true, + useAddonManager: "permanent", + expectAllGranted: false, + }); + + // Make extensions loaded with addon manager privileged. + AddonTestUtils.usePrivilegedSignatures = true; + + await test_permissions({ + manifest_version: 3, + granted_host_permissions: true, + useAddonManager: "permanent", + expectAllGranted: true, + }); + } finally { + AddonTestUtils.usePrivilegedSignatures = false; + } +}); + +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.runtime.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. +async function test_alreadyGranted(manifest_version) { + const REQUIRED_PERMISSIONS = ["geolocation"]; + const REQUIRED_ORIGINS = [ + "*://required-host.com/", + "*://*.required-domain.com/", + ]; + const OPTIONAL_PERMISSIONS = [ + ...REQUIRED_PERMISSIONS, + ...REQUIRED_ORIGINS, + "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: { + manifest_version, + permissions: REQUIRED_PERMISSIONS, + host_permissions: REQUIRED_ORIGINS, + optional_permissions: OPTIONAL_PERMISSIONS, + granted_host_permissions: true, + }, + temporarilyInstalled: true, + + 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) { + optionalPermissionsPromptHandler.sawPrompt = false; + extension.sendMessage("request", arg); + let result = await extension.awaitMessage("request.result"); + ok(result, "request() call succeeded"); + equal( + optionalPermissionsPromptHandler.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(); +} +add_task(async function test_alreadyGranted_mv2() { + return test_alreadyGranted(2); +}); +add_task(async function test_alreadyGranted_mv3() { + return test_alreadyGranted(3); +}); + +// 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", + "declarativeNetRequestFeedback", + "declarativeNetRequestWithHostAccess", + "dns", + "geckoProfiler", + "identity", + "idle", + "menus", + "menus.overrideContext", + "mozillaAddons", + "networkStatus", + "normandyAddonStudy", + "scripting", + "search", + "storage", + "telemetry", + "theme", + "unlimitedStorage", + "urlbar", + "webRequest", + "webRequestBlocking", + "webRequestFilterResponse", + "webRequestFilterResponse.serviceWorkerScript", +]; + +add_task(function test_permissions_have_localization_strings() { + let noPromptNames = Schemas.getPermissionNames([ + "PermissionNoPrompt", + "OptionalPermissionNoPrompt", + "PermissionPrivileged", + ]); + 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 when content_script match patterns are treated as optional origins. +async function test_content_script_is_optional(manifest_version) { + function background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("result", result); + } catch (e) { + browser.test.sendMessage("result", e.message); + } + } + if (msg === "getAll") { + let result = await browser.permissions.getAll(arg); + browser.test.sendMessage("granted", result); + } + }); + } + + const CS_ORIGIN = "https://test2.example.com/*"; + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version, + content_scripts: [ + { + matches: [CS_ORIGIN], + js: [], + }, + ], + }, + }); + + await extension.startup(); + + extension.sendMessage("getAll"); + let initial = await extension.awaitMessage("granted"); + deepEqual(initial.origins, [], "Nothing granted on install."); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + permissions: [], + origins: [CS_ORIGIN], + }); + let result = await extension.awaitMessage("result"); + if (manifest_version < 3) { + equal( + result, + `Cannot request origin permission for ${CS_ORIGIN} since it was not declared in the manifest`, + "Content script match pattern is not a requestable optional origin in MV2" + ); + } else { + equal(result, true, "request() for optional permissions succeeded"); + } + }); + + extension.sendMessage("getAll"); + let granted = await extension.awaitMessage("granted"); + deepEqual( + granted.origins, + manifest_version < 3 ? [] : [CS_ORIGIN], + "Granted content script origin in MV3." + ); + + await extension.unload(); +} +add_task(() => test_content_script_is_optional(2)); +add_task(() => test_content_script_is_optional(3)); + +// Check that optional permissions are not included in update prompts +async function test_permissions_prompt(manifest_version) { + function background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("result", result); + } + if (msg === "getAll") { + let result = await browser.permissions.getAll(arg); + browser.test.sendMessage("granted", result); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version, + version: "1.0", + + permissions: ["tabs"], + host_permissions: ["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"); + }); + + if (manifest_version >= 3) { + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + origins: ["https://test1.example.com/*"], + }); + let result = await extension.awaitMessage("result"); + equal(result, true, "request() for host_permissions in mv3 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, + version: "2.0", + + browser_specific_settings: { gecko: { id: extension.id } }, + + permissions: PERMS, + host_permissions: ORIGINS, + optional_permissions: ["clipboardWrite", "<all_urls>"], + }, + }); + + let install = await AddonManager.getInstallForFile(xpi); + + 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, + manifest_version < 3 ? ORIGINS : [], + "Update details includes only manifest origin permissions" + ); + + let EXPECTED = ["https://test1.example.com/*", "https://test2.example.com/*"]; + if (manifest_version < 3) { + EXPECTED.push("https://test3.example.com/*"); + } + + extension.sendMessage("getAll"); + let granted = await extension.awaitMessage("granted"); + deepEqual( + granted.origins.sort(), + EXPECTED, + "Granted origins persisted after update." + ); + + await extension.unload(); +} +add_task(async function test_permissions_prompt_mv2() { + return test_permissions_prompt(2); +}); +add_task(async function test_permissions_prompt_mv3() { + return test_permissions_prompt(3); +}); + +// Check that internal permissions can not be set and are not returned by the API. +add_task(async function test_internal_permissions() { + 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(); +}); 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..a97c3444f3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js @@ -0,0 +1,465 @@ +"use strict"; + +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", + "nativeMessaging", + "scripting", + "search", + "tabHide", + "tabs", + "webRequestBlocking", + "webRequestFilterResponse", + "webRequestFilterResponse.serviceWorkerScript", + ]; + 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.awaitBackgroundStarted(); + 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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.awaitBackgroundStarted(); + + 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.awaitBackgroundStarted(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(!hasSetting(), "setting is reset after remove"); + }); + + await extension.unload(); +}); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_permissions_event_page() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + optional_permissions: ["privacy"], + background: { persistent: false }, + }, + background() { + browser.permissions.onAdded.addListener(details => { + browser.test.sendMessage("added", details); + }); + + browser.permissions.onRemoved.addListener(details => { + browser.test.sendMessage("removed", details); + }); + }, + }); + + await extension.startup(); + let events = ["onAdded", "onRemoved"]; + for (let event of events) { + assertPersistentListeners(extension, "permissions", event, { + primed: false, + }); + } + + await extension.terminateBackground(); + for (let event of events) { + assertPersistentListeners(extension, "permissions", event, { + primed: true, + }); + } + + let permObj = { + permissions: ["privacy"], + origins: [], + }; + + // enable the permissions while the background is stopped + await ExtensionPermissions.add(extension.id, permObj, extension.extension); + let details = await extension.awaitMessage("added"); + Assert.deepEqual(permObj, details, "got added event"); + + // Restart and test that permission removal wakes the background. + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of events) { + assertPersistentListeners(extension, "permissions", event, { + primed: true, + }); + } + + // remove the permissions while the background is stopped + await ExtensionPermissions.remove( + extension.id, + permObj, + extension.extension + ); + + details = await extension.awaitMessage("removed"); + Assert.deepEqual(permObj, details, "got removed event"); + + 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..40c62d4475 --- /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", + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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..e3eeb106e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js @@ -0,0 +1,157 @@ +"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" +); + +// This test doesn't need the test extensions to be detected as privileged, +// disabling it to avoid having to keep the list of expected "internal:*" +// permissions that are added automatically to privileged extensions +// and already covered by other tests. +AddonTestUtils.usePrivilegedSignatures = false; + +// 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(); + + optionalPermissionsPromptHandler.init(); + optionalPermissionsPromptHandler.acceptPrompt = true; + + await AddonTestUtils.promiseStartupManager(); + registerCleanupFunction(async () => { + await AddonTestUtils.promiseShutdownManager(); + }); +}); + +// 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 (${JSON.stringify(perms.permissions)})` + ); + + 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 (${JSON.stringify(perms.permissions)})` + ); + equal( + perms.origins.length, + 0, + `no origin permissions after uninstall (${JSON.stringify(perms.origins)})` + ); + + // 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..0ef80de94e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js @@ -0,0 +1,1268 @@ +"use strict"; + +// Delay loading until createAppInfo is called and setup. +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +const { ExtensionAPI } = ExtensionCommon; + +// 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 { + static namespace = undefined; + primeListener(event, fire, params, isInStartup) { + if (isInStartup && event == "nonBlockingEvent") { + return; + } + // eslint-disable-next-line no-undef + let { eventName, throwError, ignoreListener } = + this.constructor.testOptions || {}; + let { namespace } = this.constructor; + + if (eventName == event) { + if (throwError) { + throw new Error(throwError); + } + if (ignoreListener) { + return; + } + } + + Services.obs.notifyObservers( + { namespace, event, fire, params }, + "prime-event-listener" + ); + + const FIRE_TOPIC = `fire-${namespace}.${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 = { namespace, event, errorMessage: err.toString() }; + Services.obs.notifyObservers(errSubject, "listener-callback-exception"); + } + } + Services.obs.addObserver(listener, FIRE_TOPIC); + + return { + unregister() { + Services.obs.notifyObservers( + { namespace, event, params }, + "unregister-primed-listener" + ); + Services.obs.removeObserver(listener, FIRE_TOPIC); + }, + convert(_fire) { + Services.obs.notifyObservers( + { namespace, event, params }, + "convert-event-listener" + ); + fire = _fire; + }, + }; + } + + getAPI(context) { + let self = this; + let { namespace } = this.constructor; + return { + [namespace]: { + testOptions(options) { + // We want to be able to test errors on startup. + // We use a global here because we test restarting AOM, + // which causes the instance of this class to be destroyed. + // eslint-disable-next-line no-undef + self.constructor.testOptions = options; + }, + onEvent1: new EventManager({ + context, + module: namespace, + event: "onEvent1", + register: (fire, ...params) => { + let data = { namespace, event: "onEvent1", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + + onEvent2: new EventManager({ + context, + module: namespace, + event: "onEvent2", + register: (fire, ...params) => { + let data = { namespace, event: "onEvent2", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + + nonBlockingEvent: new EventManager({ + context, + module: namespace, + event: "nonBlockingEvent", + register: (fire, ...params) => { + let data = { namespace, event: "nonBlockingEvent", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + }, + }; + } +}; + +function makeModule(namespace, options = {}) { + const SCHEMA = [ + { + namespace, + functions: [ + { + name: "testOptions", + type: "function", + async: true, + parameters: [ + { + name: "options", + type: "object", + additionalProperties: { + type: "any", + }, + }, + ], + }, + ], + events: [ + { + name: "onEvent1", + type: "function", + extraParameters: [{ type: "any", optional: true }], + }, + { + name: "onEvent2", + type: "function", + extraParameters: [{ type: "any", optional: true }], + }, + { + name: "nonBlockingEvent", + type: "function", + extraParameters: [{ type: "any", optional: true }], + }, + ], + }, + ]; + + const API_SCRIPT = ` + this.${namespace} = ${API.toString()}; + this.${namespace}.namespace = "${namespace}"; + `; + + // MODULE_INFO for registerModules + let { startupBlocking } = options; + return { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [[namespace]], + startupBlocking, + url: URL.createObjectURL(new Blob([API_SCRIPT])), + }; +} + +// Two modules, primary test module is startupBlocking +const MODULE_INFO = { + startupBlocking: makeModule("startupBlocking", { startupBlocking: true }), + nonStartupBlocking: makeModule("nonStartupBlocking"), +}; + +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) { + const eventDetails = subject.wrappedJSObject; + results.push(eventDetails); + if (results.length > count) { + ok( + false, + `Got unexpected ${topic} event with ${JSON.stringify(eventDetails)}` + ); + } 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; +} + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function setup() { + // The blob:-URL registered above in MODULE_INFO gets loaded at + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads"); + }); + + AddonTestUtils.init(global); + AddonTestUtils.overrideCertDB(); + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" + ); + + 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.startupBlocking.onEvent1.addListener(listener1, "listener1"); + } + if (register2) { + browser.startupBlocking.onEvent1.addListener(listener2, "listener2"); + browser.startupBlocking.onEvent2.addListener(listener3, "listener3"); + } + + browser.test.onMessage.addListener(msg => { + if (msg == "unregister2") { + browser.startupBlocking.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 [observed] = await Promise.all([ + promiseObservable("register-event-listener", 3), + extension.startup(), + ]); + check(observed, "register"); + + await extension.awaitMessage("ready"); + + // Check that the regular unregister process occurs when + // the browser shuts down. + [observed] = await Promise.all([ + promiseObservable("unregister-event-listener", 3), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + check(observed, "unregister"); + + // Check that listeners are primed at the next browser startup. + [observed] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(observed, "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); + AddonTestUtils.notifyLateStartup(); + observed = await p; + + check(observed, "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-startupBlocking.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. + [observed] = await Promise.all([ + promiseObservable("unregister-primed-listener", 3), + AddonTestUtils.promiseShutdownManager(), + ]); + check(observed, "unregister"); + + // Start up again, listener should be primed + [observed] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(observed, "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-startupBlocking.onEvent2" + ); + observed = await p; + + check(observed, "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"); + observed = await p; + check(observed, "unregister", { listener1: false, listener2: false }); + + // Check that we only get unregisters for the remaining events after + // one listener has been removed. + observed = await promiseObservable("unregister-primed-listener", 2, () => + AddonTestUtils.promiseShutdownManager() + ); + check(observed, "unregister", { listener3: false }); + + // Check that after restart, only listeners that were present at + // the end of the last session are primed. + observed = await promiseObservable("prime-event-listener", 2, () => + AddonTestUtils.promiseStartupManager() + ); + check(observed, "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") + ); + + AddonTestUtils.notifyLateStartup(); + observed = await p; + check(observed, "unregister", { listener1: false, listener3: false }); + + // Just listener1 should be registered now, fire event1 to confirm. + listenerArgs.test = "third time"; + Services.obs.notifyObservers( + { listenerArgs }, + "fire-startupBlocking.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 + observed = await promiseObservable("unregister-primed-listener", 1, () => + AddonTestUtils.promiseShutdownManager() + ); + check(observed, "unregister", { listener2: false, listener3: false }); + + observed = await promiseObservable("prime-event-listener", 1, () => + AddonTestUtils.promiseStartupManager() + ); + check(observed, "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-startupBlocking.onEvent1" + ); + equal( + (await p)[0].errorMessage, + "Error: primed listener startupBlocking.onEvent1 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.startupBlocking.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-script-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 startupBlocking.onEvent1 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. + await Promise.all([ + promiseObservable("prime-event-listener", 1), + AddonTestUtils.promiseStartupManager({ earlyStartup: false }), + ]); + info("Triggering persistent event to force the background page to start"); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent1" + ); + AddonTestUtils.notifyEarlyStartup(); + 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. + await Promise.all([ + promiseObservable("prime-event-listener", 1), + AddonTestUtils.promiseStartupManager({ earlyStartup: false }), + ]); + + info("Unloading extension before background page has loaded"); + await Promise.all([ + promiseObservable("unregister-primed-listener", 1), + extension.unload(), + ]); + + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test checks whether primed listeners are correctly primed to +// restart the background once the background has been shutdown or +// put to sleep. +add_task(async function test_background_restarted() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 1), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + }); + + // Shutdown the background page + await Promise.all([ + promiseObservable("unregister-event-listener", 1), + extension.terminateBackground(), + ]); + // When sleeping the background, its events should become persisted + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: true, + }); + + info("Triggering persistent event to force the background page to start"); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent1" + ); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test checks whether primed listeners are correctly primed to +// restart the background once the background has been shutdown or +// put to sleep. +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_eventpage_startup() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@test" } }, + background: { persistent: false }, + }, + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + let listenerNs = arg => browser.test.sendMessage("triggered-et2", arg); + browser.nonStartupBlocking.onEvent1.addListener( + listenerNs, + "triggered-et2" + ); + browser.test.onMessage.addListener(() => { + let listener = arg => browser.test.sendMessage("triggered2", arg); + browser.startupBlocking.onEvent2.addListener(listener, "triggered2"); + browser.test.sendMessage("async-registered-listener"); + }); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 2), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + extension.sendMessage("async-register-listener"); + await extension.awaitMessage("async-registered-listener"); + + async function testAfterRestart() { + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: true, + }); + // async registration should not be primed or persisted + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + primed: false, + persisted: false, + }); + + let events = trackEvents(extension); + ok( + !events.get("background-script-event"), + "Should not have received a background script event" + ); + ok( + !events.get("start-background-script"), + "Background script should not be started" + ); + + info("Triggering persistent event to force the background page to start"); + let converted = promiseObservable("convert-event-listener", 1); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent1" + ); + await extension.awaitMessage("bg_started"); + await converted; + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + ok( + events.get("background-script-event"), + "Should have received a background script event" + ); + ok( + events.get("start-background-script"), + "Background script should be started" + ); + } + + // Shutdown the background page + await Promise.all([ + promiseObservable("unregister-event-listener", 3), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + await AddonTestUtils.promiseStartupManager({ lateStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "nonStartupBlocking", "onEvent1", { + primed: false, + persisted: true, + }); + await testAfterRestart(); + + extension.sendMessage("async-register-listener"); + await extension.awaitMessage("async-registered-listener"); + + // We sleep twice to ensure startup and shutdown work correctly + info("test event listener registration during termination"); + let registrationEvents = Promise.all([ + promiseObservable("unregister-event-listener", 2), + promiseObservable("unregister-primed-listener", 1), + promiseObservable("prime-event-listener", 2), + ]); + await extension.terminateBackground(); + await registrationEvents; + + assertPersistentListeners(extension, "nonStartupBlocking", "onEvent1", { + primed: true, + persisted: true, + }); + + // Ensure onEvent2 does not fire, testAfterRestart will fail otherwise. + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent2" + ); + await testAfterRestart(); + + registrationEvents = Promise.all([ + promiseObservable("unregister-primed-listener", 2), + promiseObservable("prime-event-listener", 2), + ]); + await extension.terminateBackground(); + await registrationEvents; + await testAfterRestart(); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +// This test verifies primeListener behavior for errors or ignored listeners. +add_task(async function test_background_primeListener_errors() { + await AddonTestUtils.promiseStartupManager(); + + // The internal APIs to shutdown the background work with any + // background, and in the shutdown case, events will be persisted + // and primed for a restart. + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + // Listen for options being set so a restart will have them. + browser.test.onMessage.addListener(async (message, options) => { + if (message == "set-options") { + await browser.startupBlocking.testOptions(options); + browser.test.sendMessage("set-options:done"); + } + }); + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + let listener2 = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent2.addListener(listener2, "triggered"); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 1), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + }); + + // If an event is removed from an api, a permission is removed, + // or some other option prevents priming, ensure that + // primelistener works correctly. + // In this scenario we are testing that an event is not renewed + // on startup because the API does not re-prime it. The result + // is that the event is also not persisted. However the other + // events that are renewed should still be primed and persisted. + extension.sendMessage("set-options", { + eventName: "onEvent1", + ignoreListener: true, + }); + await extension.awaitMessage("set-options:done"); + + // Shutdown the background page + await Promise.all([ + promiseObservable("unregister-event-listener", 2), + extension.terminateBackground(), + ]); + // startupBlocking.onEvent1 was not re-primed and should not be persisted, but + // onEvent2 should still be primed and persisted. + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + persisted: false, + }); + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + primed: true, + }); + + info("Triggering persistent event to force the background page to start"); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent2" + ); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + // On restart, test an exception, it should not be re-primed. + extension.sendMessage("set-options", { + eventName: "onEvent1", + throwError: "error", + }); + await extension.awaitMessage("set-options:done"); + + // Shutdown the background page + await Promise.all([ + promiseObservable("unregister-event-listener", 1), + extension.terminateBackground(), + ]); + // startupBlocking.onEvent1 failed and should not be persisted + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + persisted: false, + }); + + info("Triggering event to verify background starts after prior error"); + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent2" + ); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + info("reset options for next test"); + extension.sendMessage("set-options", {}); + await extension.awaitMessage("set-options:done"); + + // Test errors on app restart + info("Test errors during app startup"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + await extension.awaitMessage("bg_started"); + + info("restart AOM and verify primed listener"); + await AddonTestUtils.promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: true, + persisted: true, + }); + AddonTestUtils.notifyEarlyStartup(); + + Services.obs.notifyObservers( + { listenerArgs: 123 }, + "fire-startupBlocking.onEvent1" + ); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + // Test that an exception happening during priming clears the + // event from being persisted when restarting the browser, and that + // the background correctly starts. + info("test exception during primeListener on startup"); + extension.sendMessage("set-options", { + eventName: "onEvent1", + throwError: "error", + }); + await extension.awaitMessage("set-options:done"); + + await AddonTestUtils.promiseRestartManager({ earlyStartup: false }); + await extension.awaitStartup(); + AddonTestUtils.notifyEarlyStartup(); + + // At this point, the exception results in the persisted entry + // being cleared. + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + persisted: false, + }); + + AddonTestUtils.notifyLateStartup(); + + await extension.awaitMessage("bg_started"); + + // The background added the listener back during top level execution, + // verify it is in the persisted list. + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + primed: false, + persisted: true, + }); + + // reset options + extension.sendMessage("set-options", {}); + await extension.awaitMessage("set-options:done"); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +add_task(async function test_non_background_context_listener_not_persisted() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.startupBlocking.onEvent1.addListener(listener, "triggered"); + browser.test.sendMessage( + "bg_started", + browser.runtime.getURL("extpage.html") + ); + }, + files: { + "extpage.html": `<script src="extpage.js"></script>`, + "extpage.js": function() { + let listener = arg => + browser.test.sendMessage("extpage-triggered", arg); + browser.startupBlocking.onEvent2.addListener( + listener, + "extpage-triggered" + ); + // Send a message to signal the extpage has registered the listener, + // after calling an async method and wait it to be resolved to make sure + // the addListener call to have been handled in the parent process by + // the time we will assert the persisted listeners. + browser.runtime.getPlatformInfo().then(() => { + browser.test.sendMessage("extpage_started"); + }); + }, + }, + }); + + await extension.startup(); + const extpage_url = await extension.awaitMessage("bg_started"); + + assertPersistentListeners(extension, "startupBlocking", "onEvent1", { + persisted: true, + primed: false, + }); + + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + persisted: false, + }); + + const page = await ExtensionTestUtils.loadContentPage(extpage_url); + await extension.awaitMessage("extpage_started"); + + // Expect the onEvent2 listener subscribed by the extpage to not be persisted. + assertPersistentListeners(extension, "startupBlocking", "onEvent2", { + persisted: false, + }); + + await page.close(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); + +// Test support for event page tests +const background = async function() { + let listener2 = () => + browser.test.sendMessage("triggered:non-startupblocking"); + browser.startupBlocking.onEvent1.addListener(() => {}); + browser.startupBlocking.nonBlockingEvent.addListener(() => {}); + browser.nonStartupBlocking.onEvent2.addListener(listener2); + browser.test.sendMessage("bg_started"); +}; + +const background_update = async function() { + browser.startupBlocking.onEvent1.addListener(() => {}); + browser.nonStartupBlocking.onEvent2.addListener(() => {}); + browser.test.sendMessage("updated_bg_started"); +}; + +function testPersistentListeners(extension, expect) { + for (let [ns, event, persisted, primed] of expect) { + assertPersistentListeners(extension, ns, event, { + persisted, + primed, + }); + } +} + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_startupblocking_behavior() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + background: { persistent: false }, + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("bg_started"); + + // All are persisted on startup + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", true, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + info("Test after mocked browser restart"); + await Promise.all([ + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + await AddonTestUtils.promiseStartupManager({ lateStartup: false }); + await extension.awaitStartup(); + + testPersistentListeners(extension, [ + // Startup blocking event is expected to be persisted and primed. + ["startupBlocking", "onEvent1", true, true], + // A non-startup-blocking event shouldn't be primed yet. + ["startupBlocking", "nonBlockingEvent", true, false], + // Non "Startup blocking" event is expected to be persisted but not primed yet. + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + // Complete the browser startup and fire the startup blocking event + // to let the backgrund script to run. + AddonTestUtils.notifyLateStartup(); + Services.obs.notifyObservers({}, "fire-startupBlocking.onEvent1"); + await extension.awaitMessage("bg_started"); + + info("Test after terminate background script"); + await extension.terminateBackground(); + + // After the background is terminated, all are persisted and primed. + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, true], + ["startupBlocking", "nonBlockingEvent", true, true], + ["nonStartupBlocking", "onEvent2", true, true], + ]); + + info("Notify event for the non-startupBlocking API event"); + Services.obs.notifyObservers({}, "fire-nonStartupBlocking.onEvent2"); + await extension.awaitMessage("bg_started"); + await extension.awaitMessage("triggered:non-startupblocking"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_startupblocking_behavior_upgrade() { + let id = "persistent-upgrade@test"; + await AddonTestUtils.promiseStartupManager(); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { id }, + }, + background: { persistent: false }, + }, + background, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("bg_started"); + + // All are persisted on startup + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", true, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + // Prepare the extension that will be updated. + extensionData.manifest.version = "2.0"; + extensionData.background = background_update; + + info("Test after a upgrade"); + await extension.upgrade(extensionData); + // upgrade should start the background + await extension.awaitMessage("updated_bg_started"); + + // Nothing should be primed at this point after the background + // has started. We look specifically for nonBlockingEvent to + // no longer be a part of the persisted listeners. + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", false, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); + +add_task( + { pref_set: [["extensions.eventPages.enabled", true]] }, + async function test_startupblocking_behavior_staged_upgrade() { + AddonManager.checkUpdateSecurity = false; + let id = "persistent-staged-upgrade@test"; + + // register an update file. + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + [id]: { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart.xpi", + }, + ], + }, + }, + }); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { id, update_url: `http://example.com/test_update.json` }, + }, + background: { persistent: false }, + }, + background: background_update, + }; + + // 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.background = async function() { + // we're testing persistence, not operation, so no action in listeners. + browser.startupBlocking.onEvent1.addListener(() => {}); + // nonBlockingEvent will be removed on upgrade + browser.startupBlocking.nonBlockingEvent.addListener(() => {}); + browser.nonStartupBlocking.onEvent2.addListener(() => {}); + + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + }); + + browser.test.sendMessage("bg_started"); + }; + + await AddonTestUtils.promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.awaitMessage("bg_started"); + + // All are persisted but not primed on startup + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", true, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + info("Test after a staged update"); + // first, deal with getting and staging an upgrade + 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 AddonTestUtils.promiseShutdownManager(); + + // restarting allows upgrade to proceed + await AddonTestUtils.promiseStartupManager(); + // upgrade should always start the background + await extension.awaitMessage("updated_bg_started"); + + // Since this is an upgraded addon, the background will have started + // and we no longer have primed listeners. Check only the persisted + // values, and that nonBlockingEvent is not persisted. + testPersistentListeners(extension, [ + ["startupBlocking", "onEvent1", true, false], + ["startupBlocking", "nonBlockingEvent", false, false], + ["nonStartupBlocking", "onEvent2", true, false], + ]); + + await 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..58cc0532d0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js @@ -0,0 +1,984 @@ +/* -*- 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.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +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"; +const READ_ONLY = true; + +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, data, setting) => { + // The second argument is the end of the api name, + // e.g., "network.networkPredictionEnabled". + let apiObj = setting.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, + }, + }; + + 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() { + let listeners = new Set([]); + browser.test.onMessage.addListener(async (msg, data, setting, readOnly) => { + // The second argument is the end of the api name, + // e.g., "network.webRTCIPHandlingPolicy". + let apiObj = setting.split(".").reduce((o, i) => o[i], browser.privacy); + if (msg == "get") { + browser.test.sendMessage("gettingData", await apiObj.get({})); + return; + } + + // Don't add more than one listener per apiName. We leave the + // listener to ensure we do not get more calls than we expect. + if (!listeners.has(setting)) { + apiObj.onChange.addListener(details => { + browser.test.sendMessage("settingData", details); + }); + listeners.add(setting); + } + try { + await apiObj.set(data); + } catch (e) { + browser.test.sendMessage("settingThrowsException", { + message: e.message, + }); + } + // Readonly settings will not trigger onChange, return the setting now. + if (readOnly) { + browser.test.sendMessage("settingData", await apiObj.get({})); + } + }); + } + + // 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, + }, + { behavior: "reject_third_party", nonPersistentCookies: false } + ); + // A missing nonPersistentCookies property should default to false. + await testSetting( + "websites.cookieConfig", + { behavior: "reject_third_party" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN, + }, + { behavior: "reject_third_party", nonPersistentCookies: false } + ); + // A missing behavior property should reset the pref. + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + }, + { behavior: defaultBehavior, nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_all" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT, + }, + { behavior: "reject_all", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "allow_visited" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_LIMIT_FOREIGN, + }, + { behavior: "allow_visited", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "allow_all" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT, + }, + { behavior: "allow_all", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + }, + { behavior: defaultBehavior, nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: false }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + }, + { behavior: defaultBehavior, nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER, + }, + { 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, + }, + { + 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, + }, + { 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, + }, + { + 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, + }, + { + 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 GLOBAL_PRIVACY_CONTROL_PREF_NAME = + "privacy.globalprivacycontrol.enabled"; + + Preferences.set(GLOBAL_PRIVACY_CONTROL_PREF_NAME, false); + await testGetting("network.globalPrivacyControl", {}, false); + + Preferences.set(GLOBAL_PRIVACY_CONTROL_PREF_NAME, true); + await testGetting("network.globalPrivacyControl", {}, true); + + // trying to "set" should have no effect when readonly! + extension.sendMessage( + "set", + { value: !Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME) }, + "network.globalPrivacyControl", + READ_ONLY + ); + let readOnlyGPCData = await extension.awaitMessage("settingData"); + equal( + readOnlyGPCData.value, + Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME), + "extension cannot set globalPrivacyControl" + ); + + equal(Preferences.get(GLOBAL_PRIVACY_CONTROL_PREF_NAME), true); + + 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", + READ_ONLY + ); + 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..c52b80781b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.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"; + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Management", + "resource://gre/modules/Extension.jsm" +); +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +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: { + browser_specific_settings: { + gecko: { + id: OLD_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { + 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_nonPersistentCookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js new file mode 100644 index 0000000000..c58358f239 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_nonPersistentCookies.js @@ -0,0 +1,53 @@ +"use strict"; + +const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function test_nonPersistentCookies_is_deprecated() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + async background() { + for (const nonPersistentCookies of [true, false]) { + await browser.privacy.websites.cookieConfig.set({ + value: { + behavior: "reject_third_party", + nonPersistentCookies, + }, + }); + } + + browser.test.sendMessage("background-done"); + }, + }); + + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); + }); + + const expectedMessage = /"'nonPersistentCookies' has been deprecated and it has no effect anymore."/; + + AddonTestUtils.checkMessages( + messages, + { + expected: [{ message: expectedMessage }, { message: expectedMessage }], + }, + true + ); + + 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..8d4dbdf543 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js @@ -0,0 +1,165 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +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", + browser_specific_settings: { + 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", + browser_specific_settings: { + 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..f002a4d001 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js @@ -0,0 +1,614 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Start a server for `pac.example.com` to intercept attempts to connect to it +// to load a PAC URL. We won't serve anything, but this prevents attempts at +// non-local connections if this domain is registered. +AddonTestUtils.createHttpServer({ hosts: ["pac.example.com"] }); + +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.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: "", + 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://pac.example.com", + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_PAC, + "network.proxy.autoconfig_url": "http://pac.example.com", + "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", + httpProxyAll: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_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", + 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.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, + }, + { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + // ftp: "www.mozilla.org:8081", // This line should not be sent back + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://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.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, + 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", + 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.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, + 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", + 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.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, + ssl: "www.mozilla.org:80", + socks: "mozilla.org:80", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + } + ); + + // Test resetting values. + await testProxy( + { + proxyType: "none", + http: "", + ssl: "", + socks: "", + socksVersion: 5, + passthrough: "", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT, + "network.proxy.http": "", + "network.proxy.http_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.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, + 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, + 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.awaitBackgroundStarted(); + + 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_containerIsolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js new file mode 100644 index 0000000000..9a375f68a9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_containerIsolation.js @@ -0,0 +1,59 @@ +"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() { + Services.prefs.setBoolPref("extensions.userContextIsolation.enabled", true); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertEq( + "firefox-container-2", + details.cookieStoreId, + "cookieStoreId is set" + ); + browser.test.notifyPass("allowed"); + }, + { urls: ["http://example.com/dummy"] } + ); + }, + }); + + Services.prefs.setCharPref( + "extensions.userContextIsolation.defaults.restricted", + "[1]" + ); + await extension.startup(); + + let restrictedPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 1 } + ); + + let allowedPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { + userContextId: 2, + } + ); + await extension.awaitFinish("allowed"); + + await extension.unload(); + await restrictedPage.close(); + await allowedPage.close(); + + Services.prefs.clearUserPref("extensions.userContextIsolation.enabled"); + Services.prefs.clearUserPref( + "extensions.userContextIsolation.defaults.restricted" + ); +}); 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..1e37f93683 --- /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: { + browser_specific_settings: { 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..164cf67d3e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js @@ -0,0 +1,660 @@ +"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. Before 902346, 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, null); + 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}`); + }); +}); + +// Register a proxy to be used by TCPSocket connections later. +function registerProxy(socks) { + let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService( + Ci.nsIProtocolProxyService + ); + let filter = { + QueryInterface: ChromeUtils.generateQI(["nsIProtocolProxyFilter"]), + applyFilter(uri, proxyInfo, callback) { + callback.onProxyFilterResult( + pps.newProxyInfoWithAuth( + socks.version, + socks.host, + socks.port, + socks.username, + socks.password, + "", + "", + socks.dns == "remote" + ? Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST + : 0, + -1, + null + ) + ); + }, + }; + pps.registerFilter(filter, 0); + registerCleanupFunction(() => { + pps.unregisterFilter(filter); + }); +} + +// A simple ping/pong to test the socks server with TCPSocket. +add_task(async function test_tcpsocket_proxy() { + 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, + }; + + registerProxy(socks); + await new Promise((resolve, reject) => { + let client = new TCPSocket(dest.host, dest.port); + client.onopen = () => { + client.send("PING!"); + }; + client.ondata = e => { + equal("PONG!", e.data, "socks test ok"); + resolve(); + }; + client.onerror = () => reject(); + }); +}); + +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(); +}); + +add_task(async function test_onRequest_tcpsocket_proxy() { + async function background(port) { + 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})`, + }); + + await handlingExt.startup(); + + await new Promise((resolve, reject) => { + let client = new TCPSocket("localhost", 8888); + client.onopen = () => { + client.send("PING!"); + }; + client.ondata = e => { + equal("PONG!", e.data, "socks test ok"); + resolve(); + }; + client.onerror = () => reject(); + }); + + 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..0c5f265861 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js @@ -0,0 +1,147 @@ +"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"); +}); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + 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({ earlyStartup: false }); + 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-script-event"), + false, + "Should not have gotten a background script 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-script-event"); + equal( + events.get("background-script-event"), + true, + "Should have gotten a background script event" + ); + + // Test the background page startup. + equal( + events.get("start-background-script"), + false, + "Should have gotten a background script event" + ); + + AddonTestUtils.notifyEarlyStartup(); + await new Promise(executeSoon); + + equal( + events.get("start-background-script"), + true, + "Should have gotten a background script 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..7b950355f3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js @@ -0,0 +1,660 @@ +"use strict"; + +// Tests whether we can redirect to a moz-extension: url. +ChromeUtils.defineESModuleGetters(this, { + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +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"); +}); + +server.registerPathHandler("/dummy-2", (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.runtime.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.runtime.getURL("*"); + let exturi = browser.runtime.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.runtime.getURL("*"); + let exturi = browser.runtime.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(); + +add_task(async function test_redirect_with_onHeadersReceived() { + let redirectUrl = `${gServerUrl}/dummy-2`; + + function background(initialUrl, redirectUrl) { + browser.webRequest.onCompleted.addListener( + () => { + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onHeadersReceived.addListener( + () => { + // Redirect to a different URL when we receive the headers of the + // initial request. + return { redirectUrl }; + }, + { urls: [initialUrl] }, + ["blocking"] + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/dummy", "${redirectUrl}")` + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${gServerUrl}/dummy` + ); + await extension.awaitFinish("requestCompleted"); + equal(contentPage.browser.documentURI.spec, redirectUrl, "expected redirect"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_no_redirect_with_location_in_onHeadersReceived() { + function background(initialUrl, redirectUrl) { + browser.webRequest.onCompleted.addListener( + ({ responseHeaders }) => { + // Make sure that the `Location` header is set by `onHeadersReceived`. + browser.test.assertTrue( + responseHeaders.some(({ name, value }) => { + return name.toLowerCase() === "location" && value === redirectUrl; + }), + "Location header is set" + ); + + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>"] }, + ["responseHeaders"] + ); + + browser.webRequest.onHeadersReceived.addListener( + ({ responseHeaders }) => { + return { + responseHeaders: [ + ...responseHeaders, + // Although we set a Location header here, the request shouldn't be + // redirected to `redirectUrl` because the status code hasn't been + // change (and cannot be changed from there). + { name: "Location", value: redirectUrl }, + ], + }; + }, + { urls: [initialUrl] }, + ["blocking", "responseHeaders"] + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/dummy", "${gServerUrl}/dummy-2")` + ); + await extension.startup(); + + let initialUrl = `${gServerUrl}/dummy`; + let contentPage = await ExtensionTestUtils.loadContentPage(initialUrl); + await extension.awaitFinish("requestCompleted"); + equal( + contentPage.browser.documentURI.spec, + initialUrl, + "expected no redirect" + ); + + await contentPage.close(); + await extension.unload(); +}); 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_getBackgroundPage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js new file mode 100644 index 0000000000..5af0bab639 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBackgroundPage.js @@ -0,0 +1,172 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + + Services.prefs.setBoolPref("dom.serviceWorkers.testing.enabled", true); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("dom.serviceWorkers.testing.enabled"); + Services.prefs.clearUserPref("dom.serviceWorkers.idle_timeout"); + }); +}); + +add_task(async function test_getBackgroundPage_noBackground() { + async function testBackground() { + let page = await browser.runtime.getBackgroundPage(); + browser.test.assertEq( + page, + null, + "getBackgroundPage returned null as expected" + ); + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + files: { + "page.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": testBackground, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}//page.html` + ); + await extension.awaitMessage("page-ready"); + await contentPage.close(); + + await extension.unload(); +}); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + skip_if: () => + Services.prefs.getBoolPref( + "extensions.backgroundServiceWorker.forceInTestExtension", + false + ), + }, + async function test_getBackgroundPage_eventpage() { + async function wakeupBackground() { + let page = await browser.runtime.getBackgroundPage(); + page.hello(); + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + background: { persistent: false }, + }, + + files: { + "page.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": wakeupBackground, + }, + async background() { + // eslint-disable-next-line no-unused-vars + window.hello = () => { + browser.test.sendMessage("hello"); + }; + + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await extension.terminateBackground(); + + // wake up the background + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}//page.html` + ); + await extension.awaitMessage("ready"); + await extension.awaitMessage("hello"); + await extension.awaitMessage("page-ready"); + await contentPage.close(); + + ok(true, "getBackgroundPage wakes up background"); + + await extension.unload(); + } +); + +add_task( + { + skip_if: () => { + return !WebExtensionPolicy.backgroundServiceWorkerEnabled; + }, + }, + async function test_getBackgroundPage_serviceWorker() { + async function testBackground() { + let page = await browser.runtime.getBackgroundPage(); + browser.test.assertEq( + page, + null, + "getBackgroundPage returned null as expected" + ); + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + + files: { + "sw.js": "dump('Background ServiceWorker - executed\\n');", + "page.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": testBackground, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}//page.html` + ); + await extension.awaitMessage("page-ready"); + await contentPage.close(); + + 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..254387dc6b --- /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.runtime.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..5af2647968 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js @@ -0,0 +1,599 @@ +/* -*- 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.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +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"); + +function background() { + let onInstalledDetails = null; + let onStartupFired = false; + let eventPage = browser.runtime.getManifest().background.persistent === 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(); + }); + + if (eventPage) { + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("suspended"); + }); + // an event we use to restart the background + browser.browserSettings.homepageOverride.onChange.addListener(() => { + browser.test.sendMessage("homepageOverride"); + }); + } +} + +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", + browser_specific_settings: { + 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", + browser_specific_settings: { + 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", + browser_specific_settings: { + 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.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser. + await promiseRestartManager("2"); + await extension.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + // Restart the browser. + await promiseRestartManager("2"); + await extension.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser again. + await promiseRestartManager("3"); + await extension.awaitBackgroundStarted(); + + 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", + browser_specific_settings: { + 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", + browser_specific_settings: { + 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.unload(); + 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", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_runtime_eventpage() { + const EXTENSION_ID = "test_runtime_eventpage@tests.mozilla.org"; + + await promiseStartupManager("1"); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + permissions: ["browserSettings"], + background: { + persistent: false, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: false, + }); + + info(`test onInstall does not fire after suspend`); + // we do enough here that idle timeout causes intermittent failure. + // using terminateBackground results in the same code path tested. + extension.terminateBackground(); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + + Services.prefs.setStringPref( + "browser.startup.homepage", + "http://test.example.com" + ); + await extension.awaitMessage("homepageOverride"); + // onStartup remains persisted, but not primed + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + info("test onStartup is not primed but background starts automatically"); + await promiseRestartManager(); + // onStartup is a bit special. During APP_STARTUP we do not + // prime this, we just start since it needs to. + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + await extension.awaitBackgroundStarted(); + + info("test expectEvents"); + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + info("test onInstalled fired during browser update"); + await promiseRestartManager("2"); + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + await extension.awaitBackgroundStarted(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledReason: "browser_update", + onInstalledTemporary: false, + }); + + info(`test onStarted does not fire after suspend`); + extension.terminateBackground(); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + + Services.prefs.setStringPref( + "browser.startup.homepage", + "http://homepage.example.com" + ); + await extension.awaitMessage("homepageOverride"); + // onStartup remains persisted, but not primed + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.unload(); + await promiseShutdownManager(); + } +); + +// Verify we don't regress the issue related to runtime.onStartup persistent +// listener being cleared from the startup data as part of priming all listeners +// while terminating the event page on idle timeout (Bug 1796586). +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_runtime_onStartup_eventpage() { + const EXTENSION_ID = "test_eventpage_onStartup@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + permissions: ["browserSettings"], + background: { + persistent: false, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: false, + }); + + info("Simulated idle timeout"); + extension.terminateBackground(); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + + // onStartup remains persisted, but not primed + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + + info(`test onStartup filed after restart`); + await promiseRestartManager(); + + // onStartup is a bit special. During APP_STARTUP we do not + // prime this, we just start since it needs to. + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: true, + }); + await extension.awaitBackgroundStarted(); + + info("test expectEvents"); + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + extension.terminateBackground(); + await extension.awaitMessage("suspended"); + await promiseExtensionEvent(extension, "shutdown-background-script"); + assertPersistentListeners(extension, "runtime", "onStartup", { + primed: false, + persisted: 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..dd47744a97 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js @@ -0,0 +1,170 @@ +"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.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + /* 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..2bbc9864d7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js @@ -0,0 +1,462 @@ +/* -*- 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 => { + // eslint-disable-next-line mozilla/use-isInstance -- this function runs in an extension + 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( + // eslint-disable-next-line mozilla/use-isInstance -- this function runs in an extension + 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.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + 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); + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + 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..bff2f9b728 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js @@ -0,0 +1,118 @@ +/* -*- 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: { browser_specific_settings: { 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: { browser_specific_settings: { 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()]); +}); + +add_task(async function test_sendMessage_to_badid() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + browser.runtime.sendMessage("badid@test-extension", "fake-message"), + /Could not establish connection. Receiving end does not exist./, + "Got the expected error message on sendMessage to badid ext" + ); + browser.test.sendMessage("test-done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-done"); + await extension.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..d78197f9e4 --- /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], "Location object could not be cloned."], + [[null, [circ, location], null], "Location 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_sandboxed_resource.js b/toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js new file mode 100644 index 0000000000..05489d753d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_sandboxed_resource.js @@ -0,0 +1,55 @@ +"use strict"; + +// Test that an extension page which is sandboxed may load resources +// from itself without relying on web acessible resources. +add_task(async function test_webext_background_sandbox_privileges() { + function backgroundSubframeScript() { + window.parent.postMessage(typeof browser, "*"); + } + + function backgroundScript() { + /* eslint-disable-next-line mozilla/balanced-listeners */ + window.addEventListener("message", event => { + if (event.data == "undefined") { + browser.test.notifyPass("webext-background-sandbox-privileges"); + } else { + browser.test.notifyFail("webext-background-sandbox-privileges"); + } + }); + } + + let extensionData = { + manifest: { + background: { + page: "background.html", + }, + }, + files: { + "background.html": `<!DOCTYPE> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="background.js"><\/script> + <iframe src="background-subframe.html" sandbox="allow-scripts"></iframe> + </body> + </html>`, + "background-subframe.html": `<!DOCTYPE> + <html> + <head> + <meta charset="utf-8"> + <script src="background-subframe.js"><\/script> + </head> + </html>`, + "background-subframe.js": backgroundSubframeScript, + "background.js": backgroundScript, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitFinish("webext-background-sandbox-privileges"); + 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..97345612f1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js @@ -0,0 +1,2118 @@ +"use strict"; + +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 }, + canonicalDomain: { + type: "string", + format: "canonicalDomain", + optional: "omit-key-if-missing", + }, + url: { type: "string", format: "url", optional: true }, + origin: { type: "string", format: "origin", 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" }, + }, + }, +]; + +add_task(async function() { + let wrapper = getContextWrapper(); + let url = "data:," + JSON.stringify(json); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + 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); + wrapper.verify("call", "testing", "foo", [11, true]); + + root.testing.foo(true); + wrapper.verify("call", "testing", "foo", [99, true]); + + root.testing.foo(null, true); + wrapper.verify("call", "testing", "foo", [99, true]); + + root.testing.foo(undefined, true); + wrapper.verify("call", "testing", "foo", [99, true]); + + root.testing.foo(11); + wrapper.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); + wrapper.verify("call", "testing", "bar", [null, true]); + + root.testing.baz({ prop1: "hello", prop2: 22 }); + wrapper.verify("call", "testing", "baz", [{ prop1: "hello", prop2: 22 }]); + + root.testing.baz({ prop1: "hello" }); + wrapper.verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]); + + root.testing.baz({ prop1: "hello", prop2: null }); + wrapper.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"); + wrapper.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"] }); + wrapper.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(wrapper.tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quora"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + wrapper.tallied = null; + + let g = () => 0; + root.testing.quora(g); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quora"]) + ); + Assert.equal(wrapper.tallied[3][0], g); + wrapper.tallied = null; + + root.testing.quileute(10); + wrapper.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 }); + wrapper.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(wrapper.tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quasar"]) + ); + Assert.equal(wrapper.tallied[3][0].func, f); + + root.testing.quosimodo({ a: 10, b: 20, c: 30 }); + wrapper.verify("call", "testing", "quosimodo", [{ a: 10, b: 20, c: 30 }]); + + 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", + }); + wrapper.verify("call", "testing", "patternprop", [ + { prop1: "12", prop2: "42", Prop3: "43", foo1: "x" }, + ]); + + root.testing.patternprop({ prop1: "12" }); + wrapper.verify("call", "testing", "patternprop", [{ prop1: "12" }]); + + 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"); + wrapper.verify("call", "testing", "pattern", ["DEADbeef"]); + + Assert.throws( + () => root.testing.pattern("DEADcow"), + /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/, + "should throw for non-match" + ); + + root.testing.format({ hostname: "foo" }); + wrapper.verify("call", "testing", "format", [ + { + hostname: "foo", + imageDataOrStrictRelativeUrl: null, + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: 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" + ); + Assert.throws( + () => root.testing.format({ canonicalDomain: invalid }), + /Invalid domain /, + `should throw for invalid canonicalDomain (${invalid})` + ); + } + + for (let invalid of [ + "%61", // ASCII should not be URL-encoded. + "foo:12345", // It is a common mistake to use .host instead of .hostname. + "2", // Single digit is an IPv4 address, but should be written as 0.0.0.2. + "::1", // IPv6 addresses should have brackets. + "[::1A]", // not lowercase. + "[::ffff:127.0.0.1]", // not a canonical IPv6 representation. + "UPPERCASE", // not lowercase. + "straß.de", // not punycode. + ]) { + Assert.throws( + () => root.testing.format({ canonicalDomain: invalid }), + /Invalid domain /, + `should throw for invalid canonicalDomain (${invalid})` + ); + } + + for (let valid of ["0.0.0.2", "[::1]", "[::1a]", "lowercase", "."]) { + root.testing.format({ canonicalDomain: valid }); + wrapper.verify("call", "testing", "format", [ + { + canonicalDomain: valid, + hostname: null, + imageDataOrStrictRelativeUrl: null, + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + } + + for (let valid of [ + "https://example.com", + "http://example.com", + "https://foo.bar.栃木.jp", + ]) { + root.testing.format({ origin: valid }); + } + + for (let invalid of [ + "https://example.com/testing", + "file:/foo/bar", + "file:///foo/bar", + "", + " ", + "https://foo.bar.栃木.jp/", + "https://user:pass@example.com", + "https://*.example.com", + "https://example.com#test", + "https://example.com?test", + ]) { + Assert.throws( + () => root.testing.format({ origin: invalid }), + /Invalid origin/, + "should throw for invalid origin" + ); + } + + root.testing.format({ url: "http://foo/bar", relativeUrl: "http://foo/bar" }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: null, + origin: null, + relativeUrl: "http://foo/bar", + strictRelativeUrl: null, + url: "http://foo/bar", + }, + ]); + + root.testing.format({ + relativeUrl: "foo.html", + strictRelativeUrl: "foo.html", + }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: null, + origin: null, + relativeUrl: `${wrapper.url}foo.html`, + strictRelativeUrl: `${wrapper.url}foo.html`, + url: null, + }, + ]); + + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/png;base64,A", + }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: "data:image/png;base64,A", + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A", + }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A", + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + + root.testing.format({ imageDataOrStrictRelativeUrl: "foo.html" }); + wrapper.verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: `${wrapper.url}foo.html`, + origin: null, + relativeUrl: null, + strictRelativeUrl: null, + url: 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 }); + wrapper.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" } }] }, + }); + wrapper.verify("call", "testing", "deep", [ + { foo: { bar: [{ baz: { optional: "42", required: 12 } }] } }, + ]); + + 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" + ); + + wrapper.talliedErrors.length = 0; + + root.testing.errors({ default: "0123", ignore: "0123", warn: "0123" }); + wrapper.verify("call", "testing", "errors", [ + { default: "0123", ignore: "0123", warn: "0123" }, + ]); + wrapper.checkErrors([]); + + root.testing.errors({ default: "0123", ignore: "x123", warn: "0123" }); + wrapper.verify("call", "testing", "errors", [ + { default: "0123", ignore: null, warn: "0123" }, + ]); + wrapper.checkErrors([]); + + ExtensionTestUtils.failOnSchemaWarnings(false); + root.testing.errors({ default: "0123", ignore: "0123", warn: "x123" }); + ExtensionTestUtils.failOnSchemaWarnings(true); + wrapper.verify("call", "testing", "errors", [ + { default: "0123", ignore: "0123", warn: null }, + ]); + wrapper.checkErrors(['String "x123" must match /^\\d+$/']); + + root.testing.onFoo.addListener(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onFoo"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([])); + wrapper.tallied = null; + + root.testing.onFoo.removeListener(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["removeListener", "testing", "onFoo"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + wrapper.tallied = null; + + root.testing.onFoo.hasListener(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["hasListener", "testing", "onFoo"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + wrapper.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(wrapper.tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onBar"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([10])); + wrapper.tallied = null; + + root.testing.onBar.addListener(f); + Assert.equal( + JSON.stringify(wrapper.tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onBar"]) + ); + Assert.equal(wrapper.tallied[3][0], f); + Assert.equal(JSON.stringify(wrapper.tallied[3][1]), JSON.stringify([1])); + wrapper.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/__", + }); + wrapper.verify("call", "testing", "localize", [ + { bar: "__MSG_foo__", foo: "FOO", url: "http://example.com/" }, + ]); + + 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" }); + wrapper.verify("call", "testing", "extended1", [ + { prop1: "foo", prop2: "bar" }, + ]); + + 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"); + wrapper.verify("call", "testing", "extended2", ["foo"]); + + root.testing.extended2(12); + wrapper.verify("call", "testing", "extended2", [12]); + + Assert.throws( + () => root.testing.extended2(true), + /Incorrect argument types/, + "should throw for wrong argument type" + ); + + root.testing.prop3.sub_foo(); + wrapper.verify("call", "testing.prop3", "sub_foo", []); + + Assert.throws( + () => root.testing.prop4.sub_foo(), + /root.testing.prop4 is undefined/, + "should throw for unsupported submodule" + ); + + root.foreign.foreignRef.sub_foo(); + wrapper.verify("call", "foreign.foreignRef", "sub_foo", []); + + root.testing.callderived1({ baseprop: "s1", derivedprop: "s2" }); + wrapper.verify("call", "testing", "callderived1", [ + { baseprop: "s1", derivedprop: "s2" }, + ]); + + 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 }); + wrapper.verify("call", "testing", "callderived2", [ + { baseprop: "s1", derivedprop: 42 }, + ]); + + 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() { + let wrapper = getContextWrapper(); + // 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); + + root.deprecated.property({ foo: "bar", xxx: "any", yyy: "property" }); + wrapper.verify("call", "deprecated", "property", [ + { foo: "bar", xxx: "any", yyy: "property" }, + ]); + wrapper.checkErrors([ + "Warning processing xxx: Unknown property", + "Warning processing yyy: Unknown property", + ]); + + root.deprecated.value(12); + wrapper.verify("call", "deprecated", "value", [12]); + wrapper.checkErrors([]); + + root.deprecated.value("12"); + wrapper.verify("call", "deprecated", "value", ["12"]); + wrapper.checkErrors(['Please use an integer, not "12"']); + + root.deprecated.choices(12); + wrapper.verify("call", "deprecated", "choices", [12]); + wrapper.checkErrors(["You have no choices"]); + + root.deprecated.ref("12"); + wrapper.verify("call", "deprecated", "ref", ["12"]); + wrapper.checkErrors(["Deprecated alias"]); + + root.deprecated.method(); + wrapper.verify("call", "deprecated", "method", []); + wrapper.checkErrors(["Do not call this method"]); + + void root.deprecated.accessor; + wrapper.verify("get", "deprecated", "accessor", null); + wrapper.checkErrors(["This is not the property you are looking for"]); + + root.deprecated.accessor = "x"; + wrapper.verify("set", "deprecated", "accessor", "x"); + wrapper.checkErrors(["This is not the property you are looking for"]); + + root.deprecated.onDeprecated.addListener(() => {}); + wrapper.checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.removeListener(() => {}); + wrapper.checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.hasListener(() => {}); + wrapper.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 wrapper = getContextWrapper(); + let url = "data:," + JSON.stringify(choicesJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + 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 wrapper = getContextWrapper(); + + 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'); + wrapper.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'); + wrapper.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); + let wrapper = getContextWrapper(); + + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + 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"); + wrapper.verify( + "call", + "nested.namespace.instanceOfCustomType", + "functionOnCustomType", + ["param_value"] + ); + + let fakeListener = () => {}; + instanceOfCustomType.onEvent.addListener(fakeListener); + wrapper.verify( + "addListener", + "nested.namespace.instanceOfCustomType", + "onEvent", + [fakeListener, []] + ); + instanceOfCustomType.onEvent.removeListener(fakeListener); + wrapper.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 wrapper = getContextWrapper(); + let url = "data:," + JSON.stringify($importJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + 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"); + wrapper.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"); + wrapper.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 = { + manifestVersion: 2, + 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 = { + manifestVersion: 2, + 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 = { + manifestVersion: 2, + 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 wrapper = getContextWrapper(); + + let url = "data:," + JSON.stringify(booleanEnumJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + ok(root.booleanEnum, "namespace exists"); + root.booleanEnum.paramMustBeTrue(true); + wrapper.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" + ); +}); + +let xoriginJson = [ + { + namespace: "xorigin", + types: [], + functions: [ + { + name: "foo", + type: "function", + parameters: [ + { + name: "arg", + type: "any", + }, + ], + }, + { + name: "crossFoo", + type: "function", + allowCrossOriginArguments: true, + parameters: [ + { + name: "arg", + type: "any", + }, + ], + }, + ], + }, +]; + +add_task(async function testCrossOriginArguments() { + let url = "data:," + JSON.stringify(xoriginJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let sandbox = new Cu.Sandbox("http://test.com"); + + let testingApiObj = { + foo(arg) { + sandbox.result = JSON.stringify(arg); + }, + crossFoo(arg) { + sandbox.xResult = JSON.stringify(arg); + }, + }; + + let localWrapper = { + manifestVersion: 2, + cloneScope: sandbox, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + Assert.throws( + () => root.xorigin.foo({ key: 13 }), + /Permission denied to pass object/ + ); + equal(sandbox.result, undefined, "Foo can't read cross origin object."); + + root.xorigin.crossFoo({ answer: 42 }); + equal(sandbox.xResult, '{"answer":42}', "Can read cross origin object."); +}); 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..5ff82c8158 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js @@ -0,0 +1,158 @@ +"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 = { + manifestVersion: 2, + 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..ae67a61fad --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js @@ -0,0 +1,350 @@ +"use strict"; + +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 = { + manifestVersion: 2, + 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..17295ec6b7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js @@ -0,0 +1,173 @@ +"use strict"; + +const { ExtensionProcessScript } = ChromeUtils.import( + "resource://gre/modules/ExtensionProcessScript.jsm" +); + +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 = ExtensionProcessScript.getExtensionChild(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..51fcb577bb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js @@ -0,0 +1,171 @@ +"use strict"; + +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..9c98d87c13 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js @@ -0,0 +1,160 @@ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = false; +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_setup(async () => { + 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 + ); + + await AddonTestUtils.promiseStartupManager(); + + registerCleanupFunction(async () => { + await AddonTestUtils.promiseShutdownManager(); + Services.catMan.deleteCategoryEntry( + "webextension-modules", + "test-privileged", + false + ); + }); +}); + +add_task( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + async function test_privileged_namespace_disallowed() { + // Try accessing the privileged namespace. + async function testOnce({ + isPrivileged = false, + temporarilyInstalled = false, + } = {}) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["mozillaAddons", "tabs"], + }, + background() { + browser.test.sendMessage( + "result", + browser.privileged instanceof Object + ); + }, + isPrivileged, + temporarilyInstalled, + }); + + if (temporarilyInstalled && !isPrivileged) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: /Using the privileged permission 'mozillaAddons' requires a privileged add-on/, + }, + ], + }, + true + ); + return null; + } + await extension.startup(); + let result = await extension.awaitMessage("result"); + await extension.unload(); + return result; + } + + // Prevents startup + let result = await testOnce({ temporarilyInstalled: true }); + equal( + result, + null, + "Privileged namespace should not be accessible to a regular webextension" + ); + + result = await testOnce({ isPrivileged: true }); + equal( + result, + true, + "Privileged namespace should be accessible to a webextension signed with Mozilla Extensions" + ); + + // Allows startup, no access + result = await testOnce(); + equal( + result, + false, + "Privileged namespace should not be accessible to a regular webextension" + ); + } +); + +// Test that Extension.jsm and schema correctly match. +add_task(function test_privileged_permissions_match() { + const { PRIVILEGED_PERMS } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" + ); + let perms = Schemas.getPermissionNames(["PermissionPrivileged"]); + if (AppConstants.platform == "android") { + perms.push("nativeMessaging"); + } + Assert.deepEqual( + Array.from(PRIVILEGED_PERMS).sort(), + perms.sort(), + "List of privileged permissions is correct." + ); +}); 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..6fb3e9f995 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js @@ -0,0 +1,505 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +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 = { + manifestVersion: 2, + 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..efb2c9f9bc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js @@ -0,0 +1,240 @@ +/* -*- 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"); + +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/", + manifestVersion: 2, + + 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_schemas_versioned.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js new file mode 100644 index 0000000000..3dddbbc41b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_versioned.js @@ -0,0 +1,714 @@ +"use strict"; + +let json = [ + { + namespace: "MV2", + max_manifest_version: 2, + + properties: { + PROP1: { value: 20 }, + }, + }, + { + namespace: "MV3", + min_manifest_version: 3, + properties: { + PROP1: { value: 20 }, + }, + }, + { + namespace: "mixed", + + properties: { + PROP_any: { value: 20 }, + PROP_mv3: { + $ref: "submodule", + }, + }, + types: [ + { + id: "manifestTest", + type: "object", + properties: { + // An example of extending the base type for permissions + permissions: { + type: "array", + items: { + $ref: "BaseType", + }, + optional: true, + default: [], + }, + // An example of differentiating versions of a manifest entry + multiple_choice: { + optional: true, + choices: [ + { + max_manifest_version: 2, + type: "array", + items: { + type: "string", + }, + }, + { + min_manifest_version: 3, + type: "array", + items: { + type: "boolean", + }, + }, + { + type: "array", + items: { + type: "object", + properties: { + value: { type: "boolean" }, + }, + }, + }, + ], + }, + accepting_unrecognized_props: { + optional: true, + type: "object", + properties: { + mv2_only_prop: { + type: "string", + optional: true, + max_manifest_version: 2, + }, + mv3_only_prop: { + type: "string", + optional: true, + min_manifest_version: 3, + }, + mv2_only_prop_with_default: { + type: "string", + optional: true, + default: "only in MV2", + max_manifest_version: 2, + }, + mv3_only_prop_with_default: { + type: "string", + optional: true, + default: "only in MV3", + min_manifest_version: 3, + }, + }, + additionalProperties: { $ref: "UnrecognizedProperty" }, + }, + }, + }, + { + id: "submodule", + type: "object", + min_manifest_version: 3, + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: { type: "integer" }, + }, + { + name: "sub_no_match", + type: "function", + max_manifest_version: 2, + parameters: [], + returns: { type: "integer" }, + }, + ], + }, + { + id: "BaseType", + choices: [ + { + type: "string", + enum: ["base"], + }, + ], + }, + { + id: "type_any", + type: "string", + enum: ["value1", "value2", "value3"], + }, + { + id: "type_mv2", + max_manifest_version: 2, + type: "string", + enum: ["value1", "value2", "value3"], + }, + { + id: "type_mv3", + min_manifest_version: 3, + type: "string", + enum: ["value1", "value2", "value3"], + }, + { + id: "param_type_changed", + type: "array", + items: { + choices: [ + { max_manifest_version: 2, type: "string" }, + { + min_manifest_version: 3, + type: "boolean", + }, + ], + }, + }, + { + id: "object_type_changed", + type: "object", + properties: { + prop_mv2: { + type: "string", + max_manifest_version: 2, + }, + prop_mv3: { + type: "string", + min_manifest_version: 3, + }, + prop_any: { + type: "string", + }, + }, + }, + { + id: "no_valid_choices", + type: "array", + items: { + choices: [ + { max_manifest_version: 1, type: "string" }, + { + min_manifest_version: 4, + type: "boolean", + }, + ], + }, + }, + ], + + functions: [ + { + name: "fun_param_type_versioned", + type: "function", + parameters: [{ name: "arg1", $ref: "param_type_changed" }], + }, + { + name: "fun_mv2", + max_manifest_version: 2, + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true, default: 99 }, + { name: "arg2", type: "boolean", optional: true }, + ], + }, + { + name: "fun_mv3", + min_manifest_version: 3, + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true, default: 99 }, + { name: "arg2", type: "boolean", optional: true }, + ], + }, + { + name: "fun_param_change", + type: "function", + parameters: [{ name: "arg1", $ref: "object_type_changed" }], + }, + { + name: "fun_no_valid_param", + type: "function", + parameters: [{ name: "arg1", $ref: "no_valid_choices" }], + }, + ], + events: [ + { + name: "onEvent_any", + type: "function", + }, + { + name: "onEvent_mv2", + max_manifest_version: 2, + type: "function", + }, + { + name: "onEvent_mv3", + min_manifest_version: 3, + type: "function", + }, + ], + }, + { + namespace: "mixed", + types: [ + { + $extend: "BaseType", + choices: [ + { + min_manifest_version: 3, + type: "string", + enum: ["extended"], + }, + ], + }, + ], + }, + { + namespace: "mixed", + types: [ + { + $extend: "manifestTest", + properties: { + versioned_extend: { + optional: true, + // just a simple type here + type: "string", + max_manifest_version: 2, + }, + }, + }, + ], + }, +]; + +add_task(async function setup() { + let url = "data:," + JSON.stringify(json); + Schemas._rootSchema = null; + await Schemas.load(url); + + // We want the actual errors thrown here, and not warnings recast as errors. + ExtensionTestUtils.failOnSchemaWarnings(false); +}); + +add_task(async function test_inject_V2() { + // Test injecting into a V2 context. + let wrapper = getContextWrapper(2); + + let root = {}; + Schemas.inject(root, wrapper); + + // Test elements available to both + Assert.equal(root.mixed.type_any.VALUE1, "value1", "type_any exists"); + Assert.equal(root.mixed.PROP_any, 20, "mixed value property"); + + // Test elements available to MV2 + Assert.equal(root.MV2.PROP1, 20, "MV2 value property"); + Assert.equal(root.mixed.type_mv2.VALUE2, "value2", "type_mv2 exists"); + + // Test MV3 elements not available + Assert.equal(root.MV3, undefined, "MV3 not injected"); + Assert.ok(!("MV3" in root), "MV3 not enumerable"); + Assert.equal( + root.mixed.PROP_mv3, + undefined, + "mixed submodule property does not exist" + ); + Assert.ok( + !("PROP_mv3" in root.mixed), + "mixed submodule property not enumerable" + ); + Assert.equal(root.mixed.type_mv3, undefined, "type_mv3 does not exist"); + + // Function tests + Assert.ok( + "fun_param_type_versioned" in root.mixed, + "fun_param_type_versioned exists" + ); + Assert.ok( + !!root.mixed.fun_param_type_versioned, + "fun_param_type_versioned exists" + ); + Assert.ok("fun_mv2" in root.mixed, "fun_mv2 exists"); + Assert.ok(!!root.mixed.fun_mv2, "fun_mv2 exists"); + Assert.ok(!("fun_mv3" in root.mixed), "fun_mv3 does not exist"); + Assert.ok(!root.mixed.fun_mv3, "fun_mv3 does not exist"); + + // Event tests + Assert.ok("onEvent_any" in root.mixed, "onEvent_any exists"); + Assert.ok(!!root.mixed.onEvent_any, "onEvent_any exists"); + Assert.ok("onEvent_mv2" in root.mixed, "onEvent_mv2 exists"); + Assert.ok(!!root.mixed.onEvent_mv2, "onEvent_mv2 exists"); + Assert.ok(!("onEvent_mv3" in root.mixed), "onEvent_mv3 does not exist"); + Assert.ok(!root.mixed.onEvent_mv3, "onEvent_mv3 does not exist"); + + // Function call tests + root.mixed.fun_param_type_versioned(["hello"]); + wrapper.verify("call", "mixed", "fun_param_type_versioned", [["hello"]]); + Assert.throws( + () => root.mixed.fun_param_type_versioned([true]), + /Expected string instead of true/, + "fun_param_type_versioned should throw for invalid type" + ); + + let propObj = { prop_any: "prop_any", prop_mv2: "prop_mv2" }; + root.mixed.fun_param_change(propObj); + wrapper.verify("call", "mixed", "fun_param_change", [propObj]); + + // Still throw same error as we did before we knew of the MV3 property. + Assert.throws( + () => root.mixed.fun_param_change({ prop_mv3: "prop_mv3", ...propObj }), + /Type error for parameter arg1 \(Unexpected property "prop_mv3"\)/, + "generic unexpected property message for MV3 property in MV2 extension" + ); + + // But print the more specific and descriptive warning message to console. + wrapper.checkErrors([ + `Property "prop_mv3" is unsupported in Manifest Version 2`, + ]); + + Assert.throws( + () => root.mixed.fun_no_valid_param("anything"), + /Incorrect argument types for mixed.fun_no_valid_param/, + "fun_no_valid_param should throw for versioned type" + ); +}); + +function normalizeTest(manifest, test, wrapper) { + let normalized = Schemas.normalize(manifest, "mixed.manifestTest", wrapper); + test(normalized); + // The test function should call wrapper.checkErrors if it expected errors. + // Here we call checkErrors again to ensure that there are not any unexpected + // errors left. + wrapper.checkErrors([]); +} + +add_task(async function test_normalize_V2() { + let wrapper = getContextWrapper(2); + + // Test normalize additions to the manifest structure + normalizeTest( + { + versioned_extend: "test", + }, + normalized => { + Assert.equal( + normalized.value.versioned_extend, + "test", + "resources normalized" + ); + }, + wrapper + ); + + // Test normalizing baseType + normalizeTest( + { + permissions: ["base"], + }, + normalized => { + Assert.equal( + normalized.value.permissions[0], + "base", + "resources normalized" + ); + }, + wrapper + ); + + normalizeTest( + { + permissions: ["extended"], + }, + normalized => { + Assert.ok( + normalized.error.startsWith("Error processing permissions.0"), + `manifest normalized error ${normalized.error}` + ); + }, + wrapper + ); + + // Test normalizing a value + normalizeTest( + { + multiple_choice: ["foo.html"], + }, + normalized => { + Assert.equal( + normalized.value.multiple_choice[0], + "foo.html", + "resources normalized" + ); + }, + wrapper + ); + + normalizeTest( + { + multiple_choice: [true], + }, + normalized => { + Assert.ok( + normalized.error.startsWith("Error processing multiple_choice"), + "manifest error" + ); + }, + wrapper + ); + + normalizeTest( + { + multiple_choice: [ + { + value: true, + }, + ], + }, + normalized => { + Assert.ok( + normalized.value.multiple_choice[0].value, + "resources normalized" + ); + }, + wrapper + ); + + // Tests that object definitions including additionalProperties can + // successfully accept objects from another manifest version, while ignoring + // the actual value from the non-matching manifest value. + normalizeTest( + { + accepting_unrecognized_props: { + mv2_only_prop: "mv2 here", + mv3_only_prop: "mv3 here", + }, + }, + normalized => { + equal(normalized.error, undefined, "no normalization error"); + Assert.deepEqual( + normalized.value.accepting_unrecognized_props, + { + mv2_only_prop: "mv2 here", + mv2_only_prop_with_default: "only in MV2", + }, + "Normalized object for MV2, without MV3-specific props" + ); + wrapper.checkErrors([ + `Property "mv3_only_prop" is unsupported in Manifest Version 2`, + ]); + }, + wrapper + ); +}); + +add_task(async function test_inject_V3() { + // Test injecting into a V3 context. + let wrapper = getContextWrapper(3); + + let root = {}; + Schemas.inject(root, wrapper); + + // Test elements available to both + Assert.equal(root.mixed.type_any.VALUE1, "value1", "type_any exists"); + Assert.equal(root.mixed.PROP_any, 20, "mixed value property"); + + // Test elements available to MV2 + Assert.equal(root.MV2, undefined, "MV2 value property"); + Assert.ok(!("MV2" in root), "MV2 not enumerable"); + Assert.equal(root.mixed.type_mv2, undefined, "type_mv2 does not exist"); + Assert.ok(!("type_mv2" in root.mixed), "type_mv2 not enumerable"); + + // Test MV3 elements not available + Assert.equal(root.MV3.PROP1, 20, "MV3 injected"); + Assert.ok(!!root.mixed.PROP_mv3, "mixed submodule property exists"); + Assert.equal(root.mixed.type_mv3.VALUE3, "value3", "type_mv3 exists"); + + // Versioned submodule + Assert.ok(!!root.mixed.PROP_mv3.sub_foo, "mixed submodule sub_foo exists"); + Assert.ok( + !root.mixed.PROP_mv3.sub_no_match, + "mixed submodule sub_no_match does not exist" + ); + Assert.ok( + !("sub_no_match" in root.mixed.PROP_mv3), + "mixed submodule sub_no_match is not enumerable" + ); + + // Function tests + Assert.ok( + "fun_param_type_versioned" in root.mixed, + "fun_param_type_versioned exists" + ); + Assert.ok( + !!root.mixed.fun_param_type_versioned, + "fun_param_type_versioned exists" + ); + Assert.ok(!("fun_mv2" in root.mixed), "fun_mv2 does not exist"); + Assert.ok(!root.mixed.fun_mv2, "fun_mv2 does not exist"); + Assert.ok("fun_mv3" in root.mixed, "fun_mv3 exists"); + Assert.ok(!!root.mixed.fun_mv3, "fun_mv3 exists"); + + // Event tests + Assert.ok("onEvent_any" in root.mixed, "onEvent_any exists"); + Assert.ok(!!root.mixed.onEvent_any, "onEvent_any exists"); + Assert.ok(!("onEvent_mv2" in root.mixed), "onEvent_mv2 not enumerable"); + Assert.ok(!root.mixed.onEvent_mv2, "onEvent_mv2 does not exist"); + Assert.ok("onEvent_mv3" in root.mixed, "onEvent_mv3 exists"); + Assert.ok(!!root.mixed.onEvent_mv3, "onEvent_mv3 exists"); + + // Function call tests + root.mixed.fun_param_type_versioned([true]); + wrapper.verify("call", "mixed", "fun_param_type_versioned", [[true]]); + Assert.throws( + () => root.mixed.fun_param_type_versioned(["hello"]), + /Expected boolean instead of "hello"/, + "should throw for invalid type" + ); + + let propObj = { prop_any: "prop_any", prop_mv3: "prop_mv3" }; + root.mixed.fun_param_change(propObj); + wrapper.verify("call", "mixed", "fun_param_change", [propObj]); + Assert.throws( + () => root.mixed.fun_param_change({ prop_mv2: "prop_mv2", ...propObj }), + /Unexpected property "prop_mv2"/, + "should throw for versioned type" + ); + wrapper.checkErrors([ + `Property "prop_mv2" is unsupported in Manifest Version 3`, + ]); + + root.mixed.PROP_mv3.sub_foo(); + wrapper.verify("call", "mixed.PROP_mv3", "sub_foo", []); + Assert.throws( + () => root.mixed.PROP_mv3.sub_no_match(), + /TypeError: root.mixed.PROP_mv3.sub_no_match is not a function/, + "sub_no_match should throw" + ); +}); + +add_task(async function test_normalize_V3() { + let wrapper = getContextWrapper(3); + + // Test normalize additions to the manifest structure + normalizeTest( + { + versioned_extend: "test", + }, + normalized => { + Assert.equal( + normalized.error, + `Unexpected property "versioned_extend"`, + "expected manifest error" + ); + wrapper.checkErrors([ + `Property "versioned_extend" is unsupported in Manifest Version 3`, + ]); + }, + wrapper + ); + + // Test normalizing baseType + normalizeTest( + { + permissions: ["base"], + }, + normalized => { + Assert.equal( + normalized.value.permissions[0], + "base", + "resources normalized" + ); + }, + wrapper + ); + + normalizeTest( + { + permissions: ["extended"], + }, + normalized => { + Assert.equal( + normalized.value.permissions[0], + "extended", + "resources normalized" + ); + }, + wrapper + ); + + // Test normalizing a value + normalizeTest( + { + multiple_choice: ["foo.html"], + }, + normalized => { + Assert.ok( + normalized.error.startsWith("Error processing multiple_choice"), + "manifest error" + ); + }, + wrapper + ); + + normalizeTest( + { + multiple_choice: [true], + }, + normalized => { + Assert.equal( + normalized.value.multiple_choice[0], + true, + "resources normalized" + ); + }, + wrapper + ); + + normalizeTest( + { + multiple_choice: [ + { + value: true, + }, + ], + }, + normalized => { + Assert.ok( + normalized.value.multiple_choice[0].value, + "resources normalized" + ); + }, + wrapper + ); + + wrapper.tallied = null; + + normalizeTest( + {}, + normalized => { + ok(!normalized.error, "manifest normalized"); + }, + wrapper + ); + + // Tests that object definitions including additionalProperties can + // successfully accept objects from another manifest version, while ignoring + // the actual value from the non-matching manifest value. + normalizeTest( + { + accepting_unrecognized_props: { + mv2_only_prop: "mv2 here", + mv3_only_prop: "mv3 here", + }, + }, + normalized => { + equal(normalized.error, undefined, "no normalization error"); + Assert.deepEqual( + normalized.value.accepting_unrecognized_props, + { + mv3_only_prop: "mv3 here", + mv3_only_prop_with_default: "only in MV3", + }, + "Normalized object for MV3, without MV2-specific props" + ); + wrapper.checkErrors([ + `Property "mv2_only_prop" is unsupported in Manifest Version 3`, + ]); + }, + wrapper + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js b/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js new file mode 100644 index 0000000000..2806d00553 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_script_filenames.js @@ -0,0 +1,366 @@ +"use strict"; + +// There is a rejection emitted when a JS file fails to load. On Android, +// extensions run on the main process and this rejection causes test failures, +// which is essentially why we need to allow the rejection below. +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Unable to load script.*content_script/ +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function computeSHA256Hash(text) { + const hasher = Cc["@mozilla.org/security/hash;1"].createInstance( + Ci.nsICryptoHash + ); + hasher.init(Ci.nsICryptoHash.SHA256); + hasher.update( + text.split("").map(c => c.charCodeAt(0)), + text.length + ); + return hasher.finish(true); +} + +// This function represents a dummy content or background script that the test +// cases below should attempt to load but it shouldn't be loaded because we +// check the extensions of JavaScript files in `nsJARChannel`. +function scriptThatShouldNotBeLoaded() { + browser.test.fail("this should not be executed"); +} + +function scriptThatAlwaysRuns() { + browser.test.sendMessage("content-script-loaded"); +} + +// We use these variables in combination with `scriptThatAlwaysRuns()` to send a +// signal to the extension and avoid the page to be closed too soon. +const alwaysRunsFileName = "always_run.js"; +const alwaysRunsContentScript = { + matches: ["<all_urls>"], + js: [alwaysRunsFileName], + run_at: "document_start", +}; + +add_task(async function test_content_script_filename_without_extension() { + // Filenames without any extension should not be loaded. + let invalidFileName = "content_script"; + let extensionData = { + manifest: { + content_scripts: [ + alwaysRunsContentScript, + { + matches: ["<all_urls>"], + js: [invalidFileName], + }, + ], + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [alwaysRunsFileName]: scriptThatAlwaysRuns, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("content-script-loaded"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_content_script_filename_with_invalid_extension() { + let validFileName = "content_script.js"; + let invalidFileName = "content_script.xyz"; + let extensionData = { + manifest: { + content_scripts: [ + alwaysRunsContentScript, + { + matches: ["<all_urls>"], + js: [validFileName, invalidFileName], + }, + ], + }, + files: { + // This makes sure that, when one of the content scripts fails to load, + // none of the content scripts are executed. + [validFileName]: scriptThatShouldNotBeLoaded, + [invalidFileName]: scriptThatShouldNotBeLoaded, + [alwaysRunsFileName]: scriptThatAlwaysRuns, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("content-script-loaded"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_bg_script_injects_script_with_invalid_ext() { + function backgroundScript() { + browser.test.sendMessage("background-script-loaded"); + } + + let validFileName = "background.js"; + let invalidFileName = "invalid_background.xyz"; + let extensionData = { + background() { + const script = document.createElement("script"); + script.src = "./invalid_background.xyz"; + document.head.appendChild(script); + + const validScript = document.createElement("script"); + validScript.src = "./background.js"; + document.head.appendChild(validScript); + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [validFileName]: backgroundScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("background-script-loaded"); + + await extension.unload(); +}); + +add_task(async function test_background_scripts() { + function backgroundScript() { + browser.test.sendMessage("background-script-loaded"); + } + + let validFileName = "background.js"; + let invalidFileName = "invalid_background.xyz"; + let extensionData = { + manifest: { + background: { + scripts: [invalidFileName, validFileName], + }, + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [validFileName]: backgroundScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("background-script-loaded"); + + await extension.unload(); +}); + +add_task(async function test_background_page_injects_scripts_inline() { + function injectedBackgroundScript() { + browser.test.log( + "inline script injectedBackgroundScript has been executed" + ); + browser.test.sendMessage("background-script-loaded"); + } + + let backgroundHtmlPage = "background_page.html"; + let validFileName = "injected_background.js"; + let invalidFileName = "invalid_background.xyz"; + + let inlineScript = `(${function() { + const script = document.createElement("script"); + script.src = "./invalid_background.xyz"; + document.head.appendChild(script); + const validScript = document.createElement("script"); + validScript.src = "./injected_background.js"; + document.head.appendChild(validScript); + }})()`; + + const inlineScriptSHA256 = computeSHA256Hash(inlineScript); + + info( + `Computed sha256 for the inline script injectedBackgroundScript: ${inlineScriptSHA256}` + ); + + let extensionData = { + manifest: { + background: { page: backgroundHtmlPage }, + content_security_policy: [ + "script-src", + "'self'", + `'sha256-${inlineScriptSHA256}'`, + ";", + ].join(" "), + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [validFileName]: injectedBackgroundScript, + "pre-script.js": () => { + window.onsecuritypolicyviolation = evt => { + const { violatedDirective, originalPolicy } = evt; + browser.test.fail( + `Unexpected csp violation: ${JSON.stringify({ + violatedDirective, + originalPolicy, + })}` + ); + // Let the test to fail immediately when an unexpected csp violation + // prevented the inline script from being executed successfully. + browser.test.sendMessage("background-script-loaded"); + }; + }, + [backgroundHtmlPage]: ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"></head> + <script src="pre-script.js"></script> + <script>${inlineScript}</script> + </head> + </html>`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("background-script-loaded"); + + await extension.unload(); +}); + +add_task(async function test_background_page_injects_scripts() { + // This is the initial background script loaded in the HTML page. + function backgroundScript() { + const script = document.createElement("script"); + script.src = "./invalid_background.xyz"; + document.head.appendChild(script); + + const validScript = document.createElement("script"); + validScript.src = "./injected_background.js"; + document.head.appendChild(validScript); + } + + // This is the script injected by the script defined in `backgroundScript()`. + function injectedBackgroundScript() { + browser.test.sendMessage("background-script-loaded"); + } + + let backgroundHtmlPage = "background_page.html"; + let validFileName = "injected_background.js"; + let invalidFileName = "invalid_background.xyz"; + let extensionData = { + manifest: { + background: { page: backgroundHtmlPage }, + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [validFileName]: injectedBackgroundScript, + [backgroundHtmlPage]: ` + <html> + <head> + <meta charset="utf-8"></head> + <script src="./background.js"></script> + </head> + </html>`, + "background.js": backgroundScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("background-script-loaded"); + + await extension.unload(); +}); + +add_task(async function test_background_script_registers_content_script() { + let invalidFileName = "content_script"; + let extensionData = { + manifest: { + permissions: ["<all_urls>"], + }, + async background() { + await browser.contentScripts.register({ + js: [{ file: "/content_script" }], + matches: ["<all_urls>"], + }); + browser.test.sendMessage("background-script-loaded"); + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("background-script-loaded"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources() { + function contentScript() { + const script = document.createElement("script"); + script.src = browser.runtime.getURL("content_script.css"); + script.onerror = () => { + browser.test.sendMessage("second-content-script-loaded"); + }; + + document.head.appendChild(script); + } + + let contentScriptFileName = "content_script.js"; + let invalidFileName = "content_script.css"; + let extensionData = { + manifest: { + web_accessible_resources: [invalidFileName], + content_scripts: [ + alwaysRunsContentScript, + { + matches: ["<all_urls>"], + js: [contentScriptFileName], + }, + ], + }, + files: { + [invalidFileName]: scriptThatShouldNotBeLoaded, + [contentScriptFileName]: contentScript, + [alwaysRunsFileName]: scriptThatAlwaysRuns, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("content-script-loaded"); + await extension.awaitMessage("second-content-script-loaded"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js new file mode 100644 index 0000000000..464c6bd31d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts.js @@ -0,0 +1,412 @@ +"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(); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: ["http://localhost/*"], + granted_host_permissions: true, + ...manifestProps, + }, + temporarilyInstalled: true, + ...otherProps, + }); +}; + +add_task(async function test_registerContentScripts_runAt() { + let extension = makeExtension({ + async background() { + const TEST_CASES = [ + { + title: "runAt: document_idle", + params: [ + { + id: "script-idle", + js: ["script-idle.js"], + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: false, + }, + ], + }, + { + title: "no runAt specified", + params: [ + { + id: "script-idle-default", + js: ["script-idle-default.js"], + matches: ["http://*/*/file_sample.html"], + // `runAt` defaults to `document_idle`. + persistAcrossSessions: false, + }, + ], + }, + { + title: "runAt: document_end", + params: [ + { + id: "script-end", + js: ["script-end.js"], + matches: ["http://*/*/file_sample.html"], + runAt: "document_end", + persistAcrossSessions: false, + }, + ], + }, + { + title: "runAt: document_start", + params: [ + { + id: "script-start", + js: ["script-start.js"], + matches: ["http://*/*/file_sample.html"], + runAt: "document_start", + persistAcrossSessions: false, + }, + ], + }, + ]; + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered script"); + + for (const { title, params } of TEST_CASES) { + const res = await browser.scripting.registerContentScripts(params); + browser.test.assertEq(undefined, res, `${title} - expected no result`); + } + + scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + TEST_CASES.length, + scripts.length, + `expected ${TEST_CASES.length} registered scripts` + ); + browser.test.assertEq( + JSON.stringify([ + { + id: "script-idle", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-idle.js"], + }, + { + id: "script-idle-default", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-idle-default.js"], + }, + { + id: "script-end", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_end", + persistAcrossSessions: false, + js: ["script-end.js"], + }, + { + id: "script-start", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_start", + persistAcrossSessions: false, + js: ["script-start.js"], + }, + ]), + JSON.stringify(scripts), + "got expected scripts" + ); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script-start.js": () => { + browser.test.assertEq( + "loading", + document.readyState, + "expected state 'loading' at document_start" + ); + browser.test.sendMessage("script-ran", "script-start.js"); + }, + "script-end.js": () => { + browser.test.assertTrue( + ["interactive", "complete"].includes(document.readyState), + `expected state 'interactive' or 'complete' at document_end, got: ${document.readyState}` + ); + browser.test.sendMessage("script-ran", "script-end.js"); + }, + "script-idle.js": () => { + browser.test.assertEq( + "complete", + document.readyState, + "expected state 'complete' at document_idle" + ); + browser.test.sendMessage("script-ran", "script-idle.js"); + }, + "script-idle-default.js": () => { + browser.test.assertEq( + "complete", + document.readyState, + "expected state 'complete' at document_idle" + ); + browser.test.sendMessage("script-ran", "script-idle-default.js"); + }, + }, + }); + + let scriptsRan = []; + let completePromise = new Promise(resolve => { + extension.onMessage("script-ran", result => { + scriptsRan.push(result); + + // The value below should be updated when TEST_CASES above is changed. + if (scriptsRan.length === 4) { + resolve(); + } + }); + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await completePromise; + + Assert.deepEqual( + [ + "script-start.js", + "script-end.js", + "script-idle.js", + "script-idle-default.js", + ], + scriptsRan, + "got expected executed scripts" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_register_and_unregister() { + let extension = makeExtension({ + async background() { + const script = { + id: "a-script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }; + + let results = await Promise.allSettled([ + browser.scripting.registerContentScripts([script]), + browser.scripting.unregisterContentScripts(), + ]); + + browser.test.assertEq( + 2, + results.filter(result => result.status === "fulfilled").length, + "got expected number of fulfilled promises" + ); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + + // Verify that the registered content scripts on the extension are correct. + let contentScripts = Array.from( + extension.extension.registeredContentScripts.values() + ); + Assert.equal(0, contentScripts.length, "expected no registered scripts"); + + await extension.unload(); +}); + +add_task(async function test_register_and_unregister_multiple_times() { + let extension = makeExtension({ + async background() { + // We use the same script `id` on purpose in this test. + let results = await Promise.allSettled([ + browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + ]), + browser.scripting.unregisterContentScripts(), + browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script-2.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + ]), + browser.scripting.unregisterContentScripts(), + browser.scripting.registerContentScripts([ + { + id: "a-script", + js: ["script-3.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + ]), + ]); + + browser.test.assertEq( + 5, + results.filter(result => result.status === "fulfilled").length, + "got expected number of fulfilled promises" + ); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script-1.js": "", + "script-2.js": "", + "script-3.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + + // Verify that the registered content scripts on the extension are correct. + let contentScripts = Array.from( + extension.extension.registeredContentScripts.values() + ); + Assert.equal(1, contentScripts.length, "expected 1 registered script"); + Assert.ok( + contentScripts[0].jsPaths[0].endsWith("script-3.js"), + "got expected js file" + ); + + await extension.unload(); +}); + +add_task(async function test_register_update_and_unregister() { + let extension = makeExtension({ + async background() { + const script = { + id: "a-script", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }; + const updatedScript1 = { ...script, js: ["script-2.js"] }; + const updatedScript2 = { ...script, js: ["script-3.js"] }; + + let results = await Promise.allSettled([ + browser.scripting.registerContentScripts([script]), + browser.scripting.updateContentScripts([updatedScript1]), + browser.scripting.updateContentScripts([updatedScript2]), + browser.scripting.getRegisteredContentScripts(), + browser.scripting.unregisterContentScripts(), + browser.scripting.updateContentScripts([script]), + ]); + + browser.test.assertEq(6, results.length, "expected 6 results"); + browser.test.assertEq( + "fulfilled", + results[0].status, + "expected fulfilled promise (registeredContentScripts)" + ); + browser.test.assertEq( + "fulfilled", + results[1].status, + "expected fulfilled promise (updateContentScripts)" + ); + browser.test.assertEq( + "fulfilled", + results[2].status, + "expected fulfilled promise (updateContentScripts)" + ); + browser.test.assertEq( + "fulfilled", + results[3].status, + "expected fulfilled promise (getRegisteredContentScripts)" + ); + browser.test.assertEq( + JSON.stringify([ + { + id: "a-script", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["script-3.js"], + }, + ]), + JSON.stringify(results[3].value), + "expected updated content script" + ); + browser.test.assertEq( + "fulfilled", + results[4].status, + "expected fulfilled promise (unregisterContentScripts)" + ); + browser.test.assertEq( + "rejected", + results[5].status, + "expected rejected promise because script should have been unregistered" + ); + browser.test.assertEq( + `Content script with id "${script.id}" does not exist.`, + results[5].reason.message, + "expected error message about script not found" + ); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(0, scripts.length, "expected no registered script"); + + browser.test.sendMessage("background-done"); + }, + files: { + "script-1.js": "", + "script-2.js": "", + "script-3.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + + // Verify that the registered content scripts on the extension are correct. + let contentScripts = Array.from( + extension.extension.registeredContentScripts.values() + ); + Assert.equal(0, contentScripts.length, "expected no registered scripts"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js new file mode 100644 index 0000000000..47143ebd9c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_css.js @@ -0,0 +1,330 @@ +"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(); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: ["http://localhost/*"], + granted_host_permissions: true, + ...manifestProps, + }, + allowInsecureRequests: true, + temporarilyInstalled: true, + ...otherProps, + }); +}; + +add_task(async function test_registerContentScripts_css() { + let extension = makeExtension({ + async background() { + // This script is injected in all frames after the styles so that we can + // verify the registered styles. + const checkAppliedStyleScript = { + id: "check-applied-styles", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_idle", + persistAcrossSessions: false, + js: ["check-applied-styles.js"], + }; + + // Listen to the `load-test-case` message and unregister/register new + // content scripts. + browser.test.onMessage.addListener(async (msg, data) => { + switch (msg) { + case "load-test-case": + const { title, params, skipCheckScriptRegistration } = data; + const expectedScripts = []; + + await browser.scripting.unregisterContentScripts(); + + if (!skipCheckScriptRegistration) { + await browser.scripting.registerContentScripts([ + checkAppliedStyleScript, + ]); + + expectedScripts.push(checkAppliedStyleScript); + } + + expectedScripts.push(...params); + + const res = await browser.scripting.registerContentScripts(params); + browser.test.assertEq( + res, + undefined, + `${title} - expected no result` + ); + const scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + expectedScripts.length, + scripts.length, + `${title} - expected ${expectedScripts.length} registered scripts` + ); + browser.test.assertEq( + JSON.stringify(expectedScripts), + JSON.stringify(scripts), + `${title} - got expected registered scripts` + ); + + browser.test.sendMessage(`${msg}-done`); + break; + default: + browser.test.fail(`received unexpected message: ${msg}`); + } + }); + + browser.test.sendMessage("background-ready"); + }, + files: { + "check-applied-styles.js": () => { + browser.test.sendMessage( + `background-color-${location.pathname.split("/").pop()}`, + getComputedStyle(document.querySelector("#test")).backgroundColor + ); + }, + "style-1.css": "#test { background-color: rgb(255, 0, 0); }", + "style-2.css": "#test { background-color: rgb(0, 0, 255); }", + "style-3.css": "html { background-color: rgb(0, 255, 0); }", + "script-document-start.js": async () => { + const testElement = document.querySelector("html"); + + browser.test.assertEq( + "rgb(0, 255, 0)", + getComputedStyle(testElement).backgroundColor, + "got expected style in script-document-start.js" + ); + + testElement.style.backgroundColor = "rgb(4, 4, 4)"; + }, + "check-applied-styles-document-start.js": () => { + browser.test.sendMessage( + `background-color-${location.pathname.split("/").pop()}`, + getComputedStyle(document.querySelector("html")).backgroundColor + ); + }, + "script-document-end-and-idle.js": () => { + const testElement = document.querySelector("#test"); + + browser.test.assertEq( + "rgb(255, 0, 0)", + getComputedStyle(testElement).backgroundColor, + "got expected style in script-document-end-and-idle.js" + ); + + testElement.style.backgroundColor = "rgb(5, 5, 5)"; + }, + }, + }); + + const TEST_CASES = [ + { + title: "a css file", + params: [ + { + id: "style-1", + allFrames: false, + matches: ["http://*/*/*.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css"], + }, + ], + expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"], + }, + { + title: "css and allFrames: true", + params: [ + { + id: "style-1", + allFrames: true, + matches: ["http://*/*/*.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css"], + }, + ], + expected: ["rgb(255, 0, 0)", "rgb(255, 0, 0)"], + }, + { + title: "css and allFrames: true but matches restricted to top frame", + params: [ + { + id: "style-1", + allFrames: true, + matches: ["http://*/*/file_with_iframe.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css"], + }, + ], + expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"], + }, + { + title: "css and excludeMatches set", + params: [ + { + id: "style-1", + allFrames: true, + matches: ["http://*/*/*.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css"], + excludeMatches: ["http://*/*/file_with_iframe.html"], + }, + ], + expected: ["rgba(0, 0, 0, 0)", "rgb(255, 0, 0)"], + }, + { + title: "two css files", + params: [ + { + id: "style-1-and-2", + allFrames: false, + matches: ["http://*/*/*.html"], + // TODO: Bug 1759117 - runAt should not affect css injection + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-1.css", "style-2.css"], + }, + ], + expected: ["rgb(0, 0, 255)", "rgba(0, 0, 0, 0)"], + }, + { + title: "two scripts with css", + params: [ + { + id: "style-1", + allFrames: false, + matches: ["http://*/*/*.html"], + runAt: "document_end", + persistAcrossSessions: false, + css: ["style-1.css"], + }, + { + id: "style-2", + allFrames: false, + matches: ["http://*/*/*.html"], + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-2.css"], + }, + ], + // TODO: Bug 1759117 - The expected value should be `rgb(0, 0, 255)` + // because runAt should not affect css injection and therefore the two + // styles should be applied one after the other. + expected: ["rgb(255, 0, 0)", "rgba(0, 0, 0, 0)"], + }, + { + title: "js and css with runAt: document_start", + params: [ + { + id: "js-and-style-start", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_start", + persistAcrossSessions: false, + css: ["style-3.css"], + // Inject the check script last to be able to send a message back to + // the test case. This works with `skipCheckScriptRegistration: true` + // below. + js: [ + "script-document-start.js", + "check-applied-styles-document-start.js", + ], + }, + ], + expected: ["rgb(4, 4, 4)", "rgb(4, 4, 4)"], + skipCheckScriptRegistration: true, + }, + { + title: "js and css with runAt: document_end", + params: [ + { + id: "js-and-style-end", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_end", + persistAcrossSessions: false, + css: ["style-1.css"], + // Inject the check script last to be able to send a message back to + // the test case. This works with `skipCheckScriptRegistration: true` + // below. + js: ["script-document-end-and-idle.js", "check-applied-styles.js"], + }, + ], + expected: ["rgb(5, 5, 5)", "rgb(5, 5, 5)"], + skipCheckScriptRegistration: true, + }, + { + title: "js and css with runAt: document_idle", + params: [ + { + id: "js-and-style-idle", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_idle", + persistAcrossSessions: false, + css: ["style-1.css"], + // Inject the check script last to be able to send a message back to + // the test case. This works with `skipCheckScriptRegistration: true` + // below. + js: ["script-document-end-and-idle.js", "check-applied-styles.js"], + }, + ], + expected: ["rgb(5, 5, 5)", "rgb(5, 5, 5)"], + skipCheckScriptRegistration: true, + }, + ]; + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + for (const { + title, + params, + expected, + skipCheckScriptRegistration, + } of TEST_CASES) { + extension.sendMessage("load-test-case", { + title, + params, + skipCheckScriptRegistration, + }); + await extension.awaitMessage("load-test-case-done"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_with_iframe.html` + ); + + const backgroundColors = [ + await extension.awaitMessage("background-color-file_with_iframe.html"), + await extension.awaitMessage("background-color-file_sample.html"), + ]; + + Assert.deepEqual( + expected, + backgroundColors, + `${title} - got expected colors` + ); + + await contentPage.close(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js new file mode 100644 index 0000000000..3c806439ce --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_contentScripts_file.js @@ -0,0 +1,77 @@ +"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(); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + ...manifestProps, + }, + temporarilyInstalled: true, + ...otherProps, + }); +}; + +add_task(async function test_registered_content_script_with_files() { + let extension = makeExtension({ + async background() { + const MATCHES = [ + { id: "script-1", matches: ["<all_urls>"] }, + { id: "script-2", matches: ["file:///*"] }, + { id: "script-3", matches: ["file://*/*dummy_page.html"] }, + { id: "fail-if-executed", matches: ["*://*/*"] }, + ]; + + await browser.scripting.registerContentScripts( + MATCHES.map(({ id, matches }) => ({ + id, + js: [`${id}.js`], + matches, + persistAcrossSessions: false, + })) + ); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script-1.js": () => { + browser.test.sendMessage("script-1-ran"); + }, + "script-2.js": () => { + browser.test.sendMessage("script-2-ran"); + }, + "script-3.js": () => { + browser.test.sendMessage("script-3-ran"); + }, + "fail-if-executed.js": () => { + browser.test.fail("this script should not be executed"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL); + + await Promise.all([ + extension.awaitMessage("script-1-ran"), + extension.awaitMessage("script-2-ran"), + extension.awaitMessage("script-3-ran"), + ]); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js new file mode 100644 index 0000000000..53fb77c4da --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_mv2.js @@ -0,0 +1,23 @@ +"use strict"; + +add_task(async function test_scripting_enabled_in_mv2() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting"], + }, + background() { + browser.test.assertEq( + "object", + typeof browser.scripting, + "expected scripting namespace to be defined" + ); + + browser.test.sendMessage("background-done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js new file mode 100644 index 0000000000..e0f5ead291 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_persistAcrossSessions.js @@ -0,0 +1,759 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { ExtensionScriptingStore } = ChromeUtils.import( + "resource://gre/modules/ExtensionScriptingStore.jsm" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting"], + ...manifestProps, + }, + useAddonManager: "permanent", + ...otherProps, + }); +}; + +const assertNumScriptsInStore = async (extension, expectedNum) => { + // `registerContentScripts`/`updateContentScripts()`/`unregisterContentScripts` + // call `ExtensionScriptingStore.persistAll()` without awaiting it, which + // isn't a problem in practice but this becomes a problem in this test given + // that we should make sure the startup cache is updated before checking it. + await TestUtils.waitForCondition(async () => { + let scripts = await ExtensionScriptingStore._getStoreForTesting().getByExtensionId( + extension.id + ); + return scripts.length === expectedNum; + }, "wait until the store is updated with the expected number of scripts"); + + let scripts = await ExtensionScriptingStore._getStoreForTesting().getByExtensionId( + extension.id + ); + Assert.equal( + scripts.length, + expectedNum, + `expected ${expectedNum} script in store` + ); +}; + +const verifyRegisterContentScripts = async manifestVersion => { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + manifest: { + manifest_version: manifestVersion, + }, + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.sendMessage("script-already-registered"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 1); + + await extension.awaitStartup(); + await extension.awaitMessage("script-already-registered"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}; + +add_task(async function test_registerContentScripts_mv2() { + await verifyRegisterContentScripts(2); +}); + +add_task(async function test_registerContentScripts_mv3() { + await verifyRegisterContentScripts(3); +}); + +const verifyUpdateContentScripts = async manifestVersion => { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + manifest: { + manifest_version: manifestVersion, + }, + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + await browser.scripting.updateContentScripts([ + { id: scripts[0].id, persistAcrossSessions: false }, + ]); + browser.test.sendMessage("script-updated"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + // Simulate a new session. + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 1); + + await extension.awaitStartup(); + await extension.awaitMessage("script-updated"); + await assertNumScriptsInStore(extension, 0); + + // Simulate another new session. + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}; + +add_task(async function test_updateContentScripts() { + await verifyUpdateContentScripts(2); +}); + +add_task(async function test_updateContentScripts_mv3() { + await verifyUpdateContentScripts(3); +}); + +const verifyUnregisterContentScripts = async manifestVersion => { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + manifest: { + manifest_version: manifestVersion, + }, + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + await browser.scripting.unregisterContentScripts(); + browser.test.sendMessage("script-unregistered"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + // Simulate a new session. + await AddonTestUtils.promiseRestartManager(); + + // Script should be still persisted... + await assertNumScriptsInStore(extension, 1); + await extension.awaitStartup(); + // ...and we should now enter the second branch of the background script. + await extension.awaitMessage("script-unregistered"); + await assertNumScriptsInStore(extension, 0); + + // Simulate another new session. + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}; + +add_task(async function test_unregisterContentScripts() { + await verifyUnregisterContentScripts(2); +}); + +add_task(async function test_unregisterContentScripts_mv3() { + await verifyUnregisterContentScripts(3); +}); + +add_task(async function test_reload_extension() { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + async background() { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("reload-extension", msg, `expected msg: ${msg}`); + browser.runtime.reload(); + }); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.sendMessage("script-already-registered"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + extension.sendMessage("reload-extension"); + // Wait for extension to restart, to make sure reloads works. + await AddonTestUtils.promiseWebExtensionStartup(extension.id); + await extension.awaitMessage("script-already-registered"); + await assertNumScriptsInStore(extension, 1); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}); + +add_task(async function test_disable_and_reenable_extension() { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + const script = { + id: "script", + js: ["script.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }; + + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + return; + } + + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + browser.test.sendMessage("script-already-registered"); + }, + files: { + "script.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 1); + + // Disable... + await extension.addon.disable(); + // then re-enable the extension. + await extension.addon.enable(); + + await extension.awaitMessage("script-already-registered"); + await assertNumScriptsInStore(extension, 1); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}); + +add_task(async function test_updateContentScripts_persistAcrossSessions_true() { + await AddonTestUtils.promiseStartupManager(); + + let extension = makeExtension({ + async background() { + const script = { + id: "script", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }; + + const scripts = await browser.scripting.getRegisteredContentScripts(); + + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "persist-script": + await browser.scripting.updateContentScripts([ + { id: script.id, persistAcrossSessions: true }, + ]); + browser.test.sendMessage(`${msg}-done`); + break; + + case "add-new-js": + await browser.scripting.updateContentScripts([ + { id: script.id, js: ["script-1.js", "script-2.js"] }, + ]); + browser.test.sendMessage(`${msg}-done`); + break; + + case "verify-script": + // We expect a single registered script, which is the one declared + // above but at this point we should have 2 JS files and the + // `persistAcrossSessions` option set to `true`. + browser.test.assertEq( + JSON.stringify([ + { + id: script.id, + allFrames: false, + matches: script.matches, + runAt: "document_idle", + persistAcrossSessions: true, + js: ["script-1.js", "script-2.js"], + }, + ]), + JSON.stringify(scripts), + "expected scripts" + ); + browser.test.sendMessage(`${msg}-done`); + break; + + default: + browser.test.fail(`unexpected message: ${msg}`); + } + }); + + // Only register the content script if it wasn't registered before. Since + // there is only one script, we don't check its ID. + if (!scripts.length) { + await browser.scripting.registerContentScripts([script]); + browser.test.sendMessage("script-registered"); + } else { + browser.test.sendMessage("script-already-registered"); + } + }, + files: { + "script-1.js": "", + "script-2.js": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 0); + + // Simulate a new session. + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 0); + + // We expect the script to be registered again because it isn't persisted. + await extension.awaitStartup(); + await extension.awaitMessage("script-registered"); + await assertNumScriptsInStore(extension, 0); + + // We now tell the background script to update the script to persist it + // across sessions. + extension.sendMessage("persist-script"); + await extension.awaitMessage("persist-script-done"); + + // Simulate another new session. We expect the content script to be already + // registered since it was persisted in the previous (simulated) session. + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 1); + + await extension.awaitStartup(); + await extension.awaitMessage("script-already-registered"); + await assertNumScriptsInStore(extension, 1); + + // We tell the background script to update the content script with a new JS + // file and we don't change the `persistAcrossSessions` option. + extension.sendMessage("add-new-js"); + await extension.awaitMessage("add-new-js-done"); + + // Simulate another new session. We expect the content script to have 2 JS + // files and to be registered since it was persisted in the previous + // (simulated) session and we didn't update the option. + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension, 1); + + await extension.awaitStartup(); + await extension.awaitMessage("script-already-registered"); + await assertNumScriptsInStore(extension, 1); + + // Let's verify that the script fetched by the background script is the one + // we expect at this point: it should have two JS files. + extension.sendMessage("verify-script"); + await extension.awaitMessage("verify-script-done"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension, 0); +}); + +add_task(async function test_multiple_extensions_and_scripts() { + await AddonTestUtils.promiseStartupManager(); + + let extension1 = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + if (!scripts.length) { + await browser.scripting.registerContentScripts([ + { + id: "0", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + // We should persist this script by default. + }, + { + id: "/", + js: ["script-2.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + { + id: "3", + js: ["script-3.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + ]); + browser.test.sendMessage("scripts-registered"); + return; + } + + browser.test.assertEq(2, scripts.length, "expected 2 registered scripts"); + browser.test.sendMessage("scripts-already-registered"); + }, + files: { + "script-1.js": "", + "script-2.js": "", + "script-3.js": "", + }, + }); + + let extension2 = makeExtension({ + async background() { + let scripts = await browser.scripting.getRegisteredContentScripts(); + + if (!scripts.length) { + await browser.scripting.registerContentScripts([ + { + id: "1", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + // We should persist this script by default. + }, + { + id: "2", + js: ["script-2.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: false, + }, + { + id: "\uFFFD 🍕 Boö", + js: ["script-3.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + ]); + browser.test.sendMessage("scripts-registered"); + return; + } + + browser.test.assertEq(2, scripts.length, "expected 2 registered scripts"); + browser.test.assertEq( + JSON.stringify(["script-1.js"]), + JSON.stringify(scripts[0].js), + "expected a single 'script-1.js' js file" + ); + browser.test.assertEq( + "\uFFFD 🍕 Boö", + scripts[1].id, + "expected correct ID" + ); + browser.test.sendMessage("scripts-already-registered"); + }, + files: { + "script-1.js": "", + "script-2.js": "", + "script-3.js": "", + }, + }); + + await Promise.all([extension1.startup(), extension2.startup()]); + + await Promise.all([ + extension1.awaitMessage("scripts-registered"), + extension2.awaitMessage("scripts-registered"), + ]); + await assertNumScriptsInStore(extension1, 2); + await assertNumScriptsInStore(extension2, 2); + + await AddonTestUtils.promiseRestartManager(); + await assertNumScriptsInStore(extension1, 2); + await assertNumScriptsInStore(extension2, 2); + + await Promise.all([extension1.awaitStartup(), extension2.awaitStartup()]); + await Promise.all([ + extension1.awaitMessage("scripts-already-registered"), + extension2.awaitMessage("scripts-already-registered"), + ]); + + await Promise.all([extension1.unload(), extension2.unload()]); + await AddonTestUtils.promiseShutdownManager(); + await assertNumScriptsInStore(extension1, 0); + await assertNumScriptsInStore(extension2, 0); +}); + +add_task(async function test_persisted_scripts_cleared_on_addon_updates() { + await AddonTestUtils.promiseStartupManager(); + + function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "registerContentScripts": + await browser.scripting.registerContentScripts(...args); + break; + case "unregisterContentScripts": + await browser.scripting.unregisterContentScripts(...args); + break; + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + browser.test.sendMessage(`${msg}:done`); + }); + } + + async function registerContentScript(ext, scriptFileName) { + ext.sendMessage("registerContentScripts", [ + { + id: scriptFileName, + js: [scriptFileName], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + ]); + await ext.awaitMessage("registerContentScripts:done"); + } + + let extension1Data = { + manifest: { + manifest_version: 2, + permissions: ["scripting"], + version: "1.0", + browser_specific_settings: { + // Set an explicit extension id so that extension.upgrade + // will trigger the extension to be started with the expected + // "ADDON_UPGRADE" / "ADDON_DOWNGRADE" extension.startupReason. + gecko: { id: "extension1@mochi.test" }, + }, + }, + useAddonManager: "permanent", + background, + files: { + "script-1.js": "", + }, + }; + + let extension1 = ExtensionTestUtils.loadExtension(extension1Data); + + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting"], + browser_specific_settings: { + gecko: { id: "extension2@mochi.test" }, + }, + }, + useAddonManager: "permanent", + background, + files: { + "script-2.js": "", + }, + }); + + await extension1.startup(); + await assertNumScriptsInStore(extension1, 0); + await assertIsPersistentScriptsCachedFlag(extension1, false); + + await extension2.startup(); + await assertNumScriptsInStore(extension2, 0); + await assertIsPersistentScriptsCachedFlag(extension2, false); + + await registerContentScript(extension1, "script-1.js"); + await assertNumScriptsInStore(extension1, 1); + await assertIsPersistentScriptsCachedFlag(extension1, true); + + await registerContentScript(extension2, "script-2.js"); + await assertNumScriptsInStore(extension2, 1); + await assertIsPersistentScriptsCachedFlag(extension2, true); + + info("Verify that scripts are still registered on a browser startup"); + await AddonTestUtils.promiseRestartManager(); + await extension1.awaitStartup(); + await extension2.awaitStartup(); + equal( + extension1.extension.startupReason, + "APP_STARTUP", + "Got the expected startupReason on AOM restart" + ); + + await assertNumScriptsInStore(extension1, 1); + await assertIsPersistentScriptsCachedFlag(extension1, true); + await assertNumScriptsInStore(extension2, 1); + await assertIsPersistentScriptsCachedFlag(extension2, true); + + async function testOnAddonUpdates( + extensionUpdateData, + expectedStartupReason + ) { + await extension1.upgrade(extensionUpdateData); + equal( + extension1.extension.startupReason, + expectedStartupReason, + "Got the expected startupReason on upgrade" + ); + + await assertNumScriptsInStore(extension1, 0); + await assertIsPersistentScriptsCachedFlag(extension1, false); + await assertNumScriptsInStore(extension2, 1); + await assertIsPersistentScriptsCachedFlag(extension2, true); + } + + info("Verify that scripts are cleared on upgrade"); + await testOnAddonUpdates( + { + ...extension1Data, + manifest: { + ...extension1Data.manifest, + version: "2.0", + }, + }, + "ADDON_UPGRADE" + ); + + await registerContentScript(extension1, "script-1.js"); + await assertNumScriptsInStore(extension1, 1); + + info("Verify that scripts are cleared on downgrade"); + await testOnAddonUpdates(extension1Data, "ADDON_DOWNGRADE"); + + await registerContentScript(extension1, "script-1.js"); + await assertNumScriptsInStore(extension1, 1); + + info("Verify that scripts are cleared on upgrade to same version"); + await testOnAddonUpdates(extension1Data, "ADDON_UPGRADE"); + + await extension1.unload(); + await extension2.unload(); + + await assertNumScriptsInStore(extension1, 0); + await assertIsPersistentScriptsCachedFlag(extension1, undefined); + await assertNumScriptsInStore(extension2, 0); + await assertIsPersistentScriptsCachedFlag(extension2, undefined); + + info("Verify stale persisted scripts cleared on re-install"); + // Inject a stale persisted script into the store. + await ExtensionScriptingStore._getStoreForTesting().writeMany(extension1.id, [ + { + id: "script-1.js", + allFrames: false, + matches: ["http://*/*/file_sample.html"], + runAt: "document_idle", + persistAcrossSessions: true, + js: ["script-1.js"], + }, + ]); + await assertNumScriptsInStore(extension1, 1); + const extension1Reinstalled = ExtensionTestUtils.loadExtension( + extension1Data + ); + await extension1Reinstalled.startup(); + equal( + extension1Reinstalled.extension.startupReason, + "ADDON_INSTALL", + "Got the expected startupReason on re-install" + ); + await assertNumScriptsInStore(extension1Reinstalled, 0); + await assertIsPersistentScriptsCachedFlag(extension1Reinstalled, false); + await extension1Reinstalled.unload(); + await assertNumScriptsInStore(extension1Reinstalled, 0); + await assertIsPersistentScriptsCachedFlag(extension1Reinstalled, undefined); + + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js new file mode 100644 index 0000000000..46796df296 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_startupCache.js @@ -0,0 +1,165 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { ExtensionScriptingStore } = ChromeUtils.import( + "resource://gre/modules/ExtensionScriptingStore.jsm" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const { sinon } = ChromeUtils.import("resource://testing-common/Sinon.jsm"); + +add_task(async function test_hasPersistedScripts_startup_cache() { + let extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 2, + permissions: ["scripting"], + }, + // Set the startup reason to "APP_STARTUP", used to be able to simulate + // the behavior expected on calls to `ExtensionScriptingStore.init(extension)` + // when the addon has not been just installed, but it is being loaded as part + // of the browser application starting up. + startupReason: "APP_STARTUP", + background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "registerContentScripts": + await browser.scripting.registerContentScripts(...args); + break; + case "unregisterContentScripts": + await browser.scripting.unregisterContentScripts(...args); + break; + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + browser.test.sendMessage(`${msg}:done`); + }); + }, + files: { + "script-1.js": "", + }, + }); + + await extension1.startup(); + + info(`Checking StartupCache for ${extension1.id} ${extension1.version}`); + await assertHasPersistedScriptsCachedFlag(extension1); + await assertIsPersistentScriptsCachedFlag(extension1, false); + + const store = ExtensionScriptingStore._getStoreForTesting(); + + extension1.sendMessage("registerContentScripts", [ + { + id: "some-script-id", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + ]); + await extension1.awaitMessage("registerContentScripts:done"); + + // `registerContentScripts()` calls `ExtensionScriptingStore.persistAll()` + // without await it, which isn't a problem in practice but this becomes a + // problem in this test given that we should make sure the startup cache + // is updated before checking it. + await TestUtils.waitForCondition(async () => { + const scripts = await store.getAll(extension1.id); + return !!scripts.length; + }, "Wait for stored scripts list to not be empty"); + await assertIsPersistentScriptsCachedFlag(extension1, true); + + extension1.sendMessage("unregisterContentScripts", { + ids: ["some-script-id"], + }); + await extension1.awaitMessage("unregisterContentScripts:done"); + + await TestUtils.waitForCondition(async () => { + const scripts = await store.getAll(extension1.id); + return !scripts.length; + }, "Wait for stored scripts list to be empty"); + await assertIsPersistentScriptsCachedFlag(extension1, false); + + const storeGetAllSpy = sinon.spy(store, "getAll"); + const cleanupSpies = () => { + storeGetAllSpy.restore(); + }; + + // NOTE: ExtensionScriptingStore.initExtension is usually only called once + // during the extension startup. + // + // This test calls the method after startup was completed, which does not + // happen in practice, but it allows us to simulate what happens under different + // store and startup cache conditions and more explicitly cover the expectation + // that store.getAll isn't going to be called more than once internally + // when the hasPersistedScripts boolean flag wasn't in the StartupCache + // and had to be recomputed. + equal( + extension1.extension.startupReason, + "APP_STARTUP", + "Got the expected extension.startupReason" + ); + await ExtensionScriptingStore.initExtension(extension1.extension); + equal(storeGetAllSpy.callCount, 0, "Expect store.getAll to not be called"); + + Services.obs.notifyObservers(null, "startupcache-invalidate"); + + await ExtensionScriptingStore.initExtension(extension1.extension); + equal(storeGetAllSpy.callCount, 1, "Expect store.getAll to be called once"); + + extension1.sendMessage("registerContentScripts", [ + { + id: "some-script-id", + js: ["script-1.js"], + matches: ["http://*/*/file_sample.html"], + persistAcrossSessions: true, + }, + ]); + await extension1.awaitMessage("registerContentScripts:done"); + + await TestUtils.waitForCondition(async () => { + const scripts = await store.getAll(extension1.id); + return !!scripts.length; + }, "Wait for stored scripts list to not be empty"); + await assertIsPersistentScriptsCachedFlag(extension1, true); + + // Make sure getAll is only called once when we don't have + // scripting.hasPersistedScripts flag cached. + storeGetAllSpy.resetHistory(); + Services.obs.notifyObservers(null, "startupcache-invalidate"); + await ExtensionScriptingStore.initExtension(extension1.extension); + equal(storeGetAllSpy.callCount, 1, "Expect store.getAll to be called once"); + + cleanupSpies(); + + const extId = extension1.id; + const extVersion = extension1.version; + await assertIsPersistentScriptsCachedFlag( + { id: extId, version: extVersion }, + true + ); + await extension1.unload(); + await assertIsPersistentScriptsCachedFlag( + { id: extId, version: extVersion }, + undefined + ); + + const { StartupCache } = ExtensionParent; + const allCachedGeneral = StartupCache._data.get("general"); + equal( + allCachedGeneral.has(extId), + false, + "Expect the extension to have been removed from the StartupCache" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js new file mode 100644 index 0000000000..9d3bf1576c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_scripting_updateContentScripts.js @@ -0,0 +1,114 @@ +"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(); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const makeExtension = ({ manifest: manifestProps, ...otherProps }) => { + return ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + permissions: ["scripting"], + host_permissions: ["http://localhost/*"], + granted_host_permissions: true, + ...manifestProps, + }, + temporarilyInstalled: true, + ...otherProps, + }); +}; + +add_task(async function test_scripting_updateContentScripts() { + let extension = makeExtension({ + async background() { + const script = { + id: "a-script", + js: ["script-1.js"], + matches: ["http://*/*/*.html"], + persistAcrossSessions: false, + }; + + await browser.scripting.registerContentScripts([script]); + await browser.scripting.updateContentScripts([ + { + id: script.id, + js: ["script-2.js"], + }, + ]); + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq(1, scripts.length, "expected 1 registered script"); + + browser.test.sendMessage("background-ready"); + }, + files: { + "script-1.js": () => { + browser.test.fail("script-1 should not be executed"); + }, + "script-2.js": () => { + browser.test.sendMessage( + `script-2 executed in ${location.pathname.split("/").pop()}` + ); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitMessage("script-2 executed in file_sample.html"); + + await extension.unload(); + await contentPage.close(); +}); + +add_task( + async function test_scripting_updateContentScripts_non_default_values() { + let extension = makeExtension({ + async background() { + const script = { + id: "a-script", + allFrames: true, + matches: ["http://*/*/*.html"], + runAt: "document_start", + persistAcrossSessions: false, + css: ["style.js"], + excludeMatches: ["http://*/*/foobar.html"], + js: ["script.js"], + }; + + await browser.scripting.registerContentScripts([script]); + + // This should not modify the previously registered script. + await browser.scripting.updateContentScripts([{ id: script.id }]); + + let scripts = await browser.scripting.getRegisteredContentScripts(); + browser.test.assertEq( + JSON.stringify([script]), + JSON.stringify(scripts), + "expected unmodified registered script" + ); + + browser.test.sendMessage("background-done"); + }, + files: { + "script.js": "", + "style.css": "", + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-done"); + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js b/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js new file mode 100644 index 0000000000..e8b3dcfca8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_secfetch.js @@ -0,0 +1,352 @@ +/* -*- 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); + +const server = createHttpServer({ + // We need the 127.0.0.1 proxy because the sec-fetch headers are not sent to + // "127.0.0.1:<any port other than 80 or 443>". + hosts: ["127.0.0.1", "127.0.0.2"], +}); + +server.registerPathHandler("/page.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Access-Control-Allow-Origin", "*"); +}); + +server.registerPathHandler("/return_headers", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain"); + response.setHeader("Access-Control-Allow-Origin", "*"); + if (request.method === "OPTIONS") { + // Handle CORS preflight request. + response.setHeader("Access-Control-Allow-Methods", "GET, PUT"); + return; + } + + let headers = {}; + for (let header of [ + "sec-fetch-site", + "sec-fetch-dest", + "sec-fetch-mode", + "sec-fetch-user", + ]) { + if (request.hasHeader(header)) { + headers[header] = request.getHeader(header); + } + } + + if (request.hasHeader("origin")) { + headers.origin = request + .getHeader("origin") + .replace(/moz-extension:\/\/[^\/]+/, "moz-extension://<placeholder>"); + } + + response.write(JSON.stringify(headers)); +}); + +async function contentScript() { + let content_fetch; + if (browser.runtime.getManifest().manifest_version === 2) { + content_fetch = content.fetch; + } else { + // In MV3, there is no content variable. + browser.test.assertEq(typeof content, "undefined", "no .content in MV3"); + // In MV3, window.fetch is the original fetch with the page's principal. + content_fetch = window.fetch.bind(window); + } + let results = await Promise.allSettled([ + // A cross-origin request from the content script. + fetch("http://127.0.0.1/return_headers").then(res => res.json()), + // A cross-origin request that behaves as if it was sent by the content it + // self. + content_fetch("http://127.0.0.1/return_headers").then(res => res.json()), + // A same-origin request that behaves as if it was sent by the content it + // self. + content_fetch("http://127.0.0.2/return_headers").then(res => res.json()), + // A same-origin request from the content script. + fetch("http://127.0.0.2/return_headers").then(res => res.json()), + // Non GET or HEAD request, triggers CORS preflight. + fetch("http://127.0.0.2/return_headers", { method: "PUT" }).then(res => + res.json() + ), + ]); + + results = results.map(({ value, reason }) => value ?? reason.message); + + browser.test.sendMessage("content_results", results); +} + +async function runSecFetchTest(test) { + let data = { + async background() { + let site = await new Promise(resolve => { + browser.test.onMessage.addListener(msg => { + resolve(msg); + }); + }); + + let results = await Promise.all([ + fetch(`${site}/return_headers`).then(res => res.json()), + // Non GET or HEAD request, triggers CORS preflight. + fetch(`${site}/return_headers`, { method: "PUT" }).then(res => + res.json() + ), + ]); + browser.test.sendMessage("background_results", results); + }, + manifest: { + manifest_version: test.manifest_version, + content_scripts: [ + { + matches: ["http://127.0.0.2/*"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }; + + if (data.manifest.manifest_version == 3) { + // Automatically grant permissions so that the content script can run. + data.manifest.granted_host_permissions = true; + // Needed to use granted_host_permissions in tests: + data.temporarilyInstalled = true; + // Work-around for bug 1766752: + data.manifest.host_permissions = ["http://127.0.0.2/*"]; + // (note: ^ host_permissions may be replaced/extended below). + } + + // The sec-fetch-* headers are only send to potentially trust worthy origins. + // We use 127.0.0.1 to avoid setting up an https server. + const site = "http://127.0.0.1"; + + if (test.permission) { + // MV3 requires permissions to be set in permissions. ExtensionTestCommon + // will replace host_permissions with permissions in MV2. + data.manifest.host_permissions = ["http://127.0.0.2/*", `${site}/*`]; + } + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + extension.sendMessage(site); + let backgroundResults = await extension.awaitMessage("background_results"); + Assert.deepEqual(backgroundResults, test.expectedBackgroundHeaders); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://127.0.0.2/page.html` + ); + let contentResults = await extension.awaitMessage("content_results"); + Assert.deepEqual(contentResults, test.expectedContentHeaders); + await contentPage.close(); + + await extension.unload(); +} + +add_task(async function test_fetch_without_permissions_mv2() { + await runSecFetchTest({ + manifest_version: 2, + permission: false, + expectedBackgroundHeaders: [ + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + ], + expectedContentHeaders: [ + // TODO bug 1605197: Support cors without permissions in MV2. + "NetworkError when attempting to fetch resource.", + // Expectation: + // { + // "sec-fetch-site": "cross-site", + // "sec-fetch-mode": "cors", + // "sec-fetch-dest": "empty", + // }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + ], + }); +}); + +add_task(async function test_fetch_with_permissions_mv2() { + await runSecFetchTest({ + manifest_version: 2, + permission: true, + expectedBackgroundHeaders: [ + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + ], + expectedContentHeaders: [ + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + ], + }); +}); + +add_task(async function test_fetch_without_permissions_mv3() { + await runSecFetchTest({ + manifest_version: 3, + permission: false, + expectedBackgroundHeaders: [ + // Same as in test_fetch_without_permissions_mv2. + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + ], + expectedContentHeaders: [ + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + ], + }); +}); + +add_task(async function test_fetch_with_permissions_mv3() { + await runSecFetchTest({ + manifest_version: 3, + permission: true, + expectedBackgroundHeaders: [ + { + // Same as in test_fetch_with_permissions_mv2. + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "moz-extension://<placeholder>", + }, + ], + expectedContentHeaders: [ + // All expectations the same as in test_fetch_without_permissions_mv3. + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "cross-site", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + }, + { + "sec-fetch-site": "same-origin", + "sec-fetch-mode": "cors", + "sec-fetch-dest": "empty", + origin: "http://127.0.0.2", + }, + ], + }); +}); 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_array_buffer.js b/toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js new file mode 100644 index 0000000000..4ae644284c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shared_array_buffer.js @@ -0,0 +1,104 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_shared_array_buffer_worker() { + const extension_description = { + isPrivileged: null, + async background() { + browser.test.onMessage.addListener(async isPrivileged => { + const worker = new Worker("worker.js"); + worker.isPrivileged = isPrivileged; + worker.onmessage = function(e) { + const msg = `${ + this.isPrivileged + ? "privileged addon can" + : "non-privileged addon can't" + } instantiate a SharedArrayBuffer + in a worker`; + if (e.data === this.isPrivileged) { + browser.test.succeed(msg); + } else { + browser.test.fail(msg); + } + browser.test.sendMessage("test-sab-worker:done"); + }; + }); + }, + files: { + "worker.js": function() { + try { + new SharedArrayBuffer(1); + this.postMessage(true); + } catch (e) { + this.postMessage(false); + } + }, + }, + }; + + // This test attempts to verify that a worker inside a privileged addon + // is allowed to instantiate a SharedArrayBuffer + extension_description.isPrivileged = true; + let extension = ExtensionTestUtils.loadExtension(extension_description); + await extension.startup(); + extension.sendMessage(extension_description.isPrivileged); + await extension.awaitMessage("test-sab-worker:done"); + await extension.unload(); + + // This test attempts to verify that a worker inside a non privileged addon + // is not allowed to instantiate a SharedArrayBuffer + extension_description.isPrivileged = false; + extension = ExtensionTestUtils.loadExtension(extension_description); + await extension.startup(); + extension.sendMessage(extension_description.isPrivileged); + await extension.awaitMessage("test-sab-worker:done"); + await extension.unload(); +}); + +add_task(async function test_shared_array_buffer_content() { + let extension_description = { + isPrivileged: null, + async background() { + browser.test.onMessage.addListener(async isPrivileged => { + let succeed = null; + try { + new SharedArrayBuffer(1); + succeed = true; + } catch (e) { + succeed = false; + } finally { + const msg = `${ + isPrivileged ? "privileged addon can" : "non-privileged addon can't" + } instantiate a SharedArrayBuffer + in the main thread`; + if (succeed === isPrivileged) { + browser.test.succeed(msg); + } else { + browser.test.fail(msg); + } + browser.test.sendMessage("test-sab-content:done"); + } + }); + }, + }; + + // This test attempts to verify that a non privileged addon + // is allowed to instantiate a sharedarraybuffer + extension_description.isPrivileged = true; + let extension = ExtensionTestUtils.loadExtension(extension_description); + await extension.startup(); + extension.sendMessage(extension_description.isPrivileged); + await extension.awaitMessage("test-sab-content:done"); + await extension.unload(); + + // This test attempts to verify that a non privileged addon + // is not allowed to instantiate a sharedarraybuffer + extension_description.isPrivileged = false; + extension = ExtensionTestUtils.loadExtension(extension_description); + await extension.startup(); + extension.sendMessage(extension_description.isPrivileged); + await extension.awaitMessage("test-sab-content:done"); + 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..cc59e91b89 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.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 { + ExtensionParent: { GlobalManager }, +} = ChromeUtils.import("resource://gre/modules/ExtensionParent.jsm"); + +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..6eec5e589a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js @@ -0,0 +1,190 @@ +/* -*- 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(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +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(); +}); + +add_task(async function test_policy_temporarilyInstalled() { + await AddonTestUtils.promiseStartupManager(); + + let extensionData = { + manifest: { + manifest_version: 2, + }, + }; + + async function runTest(useAddonManager) { + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + useAddonManager, + }); + + const expected = useAddonManager === "temporary"; + await extension.startup(); + const { temporarilyInstalled } = WebExtensionPolicy.getByID(extension.id); + equal( + temporarilyInstalled, + expected, + `Got the expected WebExtensionPolicy.temporarilyInstalled value on "${useAddonManager}"` + ); + await extension.unload(); + } + + await runTest("temporary"); + await runTest("permanent"); +}); + +add_task(async function test_manifest_allowInsecureRequests() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let extensionData = { + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + equal( + extension.extension.manifest.content_security_policy.extension_pages, + `script-src 'self'`, + "insecure allowed" + ); + await extension.unload(); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_manifest_allowInsecureRequests_throws() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let extensionData = { + allowInsecureRequests: true, + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self'`, + }, + }, + }; + + await Assert.throws( + () => ExtensionTestUtils.loadExtension(extensionData), + /allowInsecureRequests cannot be used with manifest.content_security_policy/, + "allowInsecureRequests with content_security_policy cannot be loaded" + ); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); 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..4d8e764006 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js @@ -0,0 +1,178 @@ +/* -*- 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.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const ADDON_ID = "test-startup-cache@xpcshell.mozilla.org"; + +function makeExtension(opts) { + return { + useAddonManager: "permanent", + + manifest: { + version: opts.version, + browser_specific_settings: { 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 test_langpack_startup_cache() { + 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, + browser_specific_settings: { + 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.awaitBackgroundStarted(); + + 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.awaitBackgroundStarted(); + + 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.awaitBackgroundStarted(); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.1", "Got expected manifest name"); + + info("Disable locale 'fr'"); + addon = await AddonManager.getAddonByID("@test-langpack"); + + // We disable the installed langpack instead of uninstalling it + // because the xpi file may technically be still in use by the + // time the XPIProvider will try to remove the file and will + // make this test to fail intermittently on windows. + // + // Disabling the addon is equivalent from the perspective of this + // test case, and the langpack xpi will be uninstalled automatically + // at the end of this test case by AddonTestUtils (from its + // cleanupTempXPIs method, which will also force a GC if the + // file fails to be removed after we flushed the jar cache). + await addon.disable(); + ok(!Services.locale.availableLocales.includes("fr"), "fr locale is removed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js new file mode 100644 index 0000000000..8ac968c13d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache_telemetry.js @@ -0,0 +1,167 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm" +); + +ChromeUtils.defineESModuleGetters(this, { + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const ADDON_ID = "test-startup-cache-telemetry@xpcshell.mozilla.org"; + +add_setup(async () => { + // FOG needs a profile directory to put its data in. + do_get_profile(); + // FOG needs to be initialized in order for data to flow. + Services.fog.initializeFOG(); + + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_startupCache_write_byteLength() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: ADDON_ID } }, + }, + }); + + await extension.startup(); + + const { StartupCache } = ExtensionParent; + + const aomStartup = Cc[ + "@mozilla.org/addons/addon-manager-startup;1" + ].getService(Ci.amIAddonManagerStartup); + + let expectedByteLength = new Uint8Array( + aomStartup.encodeBlob(StartupCache._data) + ).byteLength; + + equal( + typeof expectedByteLength, + "number", + "Got a numeric byteLength for the expected startupCache data" + ); + ok(expectedByteLength > 0, "Got a non-zero byteLength as expected"); + await StartupCache._saveNow(); + + let scalars = TelemetryTestUtils.getProcessScalars("parent"); + equal( + scalars["extensions.startupCache.write_byteLength"], + expectedByteLength, + "Got the expected value set in the 'extensions.startupCache.write_byteLength' scalar" + ); + + await extension.unload(); +}); + +add_task(async function test_startupCache_read_errors() { + const { StartupCache } = ExtensionParent; + + // Clear any pre-existing keyed scalar. + TelemetryTestUtils.getProcessScalars("parent", /* keyed */ true, true); + + // Temporarily point StartupCache._file to a path that is + // not going to exist for sure. + Assert.notEqual( + StartupCache.file, + null, + "Got a StartupCache._file non-null property as expected" + ); + const oldFile = StartupCache.file; + const restoreStartupCacheFile = () => (StartupCache.file = oldFile); + StartupCache.file = `${StartupCache.file}.non_existing_file.${Math.random()}`; + registerCleanupFunction(restoreStartupCacheFile); + + // Make sure the _readData has been called and we can expect + // the extensions.startupCache.read_errors scalar to have + // been recorded. + await StartupCache._readData(); + + let scalars = TelemetryTestUtils.getProcessScalars( + "parent", + /* keyed */ true + ); + Assert.deepEqual( + scalars["extensions.startupCache.read_errors"], + { + NotFoundError: 1, + }, + "Got the expected value set in the 'extensions.startupCache.read_errors' keyed scalar" + ); + + restoreStartupCacheFile(); +}); + +async function test_startupCache_load_timestamps() { + const { StartupCache } = ExtensionParent; + + // Clear any pre-existing keyed scalar and Glean metrics data. + TelemetryTestUtils.getProcessScalars("parent", false, true); + Services.fog.testResetFOG(); + + let gleanMetric = Glean.extensions.startupCacheLoadTime.testGetValue(); + equal( + typeof gleanMetric, + "undefined", + "Expect extensions.startup_cache_load_time Glean metric to be initially undefined" + ); + + // Make sure the _readData has been called and we can expect + // the startupCache load telemetry timestamps to have been + // recorded. + await StartupCache._readData(); + + info( + "Verify telemetry recorded for the 'extensions.startup_cache_load_time' Glean metric" + ); + + gleanMetric = Glean.extensions.startupCacheLoadTime.testGetValue(); + equal( + typeof gleanMetric, + "number", + "Expect extensions.startup_cache_load_time Glean metric to be set to a number" + ); + + info( + "Verify telemetry mirrored into the 'extensions.startupCache.load_time' scalar" + ); + + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + + equal( + typeof scalars["extensions.startupCache.load_time"], + "number", + "Expect extensions.startupCache.load_time mirrored scalar to be set to a number" + ); + + equal( + scalars["extensions.startupCache.load_time"], + gleanMetric, + "Expect the glean metric and mirrored scalar to be set to the same value" + ); +} + +add_task( + // Bug 1752139: this test can be re-enabled once Services.fog.testResetFOG() + // is implemented also on Android. + { skip_if: () => AppConstants.platform === "android" }, + test_startupCache_load_timestamps +); 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..9dab993533 --- /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.loadedJSModules + .concat(Cu.loadedESModules) + .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..0c53a4483b --- /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: { + browser_specific_settings: { 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..08f5f6fefe --- /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.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +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"], + browser_specific_settings: { 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"], + browser_specific_settings: { 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"], + browser_specific_settings: { + 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"], + browser_specific_settings: { + 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.sys.mjs +// 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"], + browser_specific_settings: { + 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", function() {}); + + 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"], + browser_specific_settings: { + 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"], + browser_specific_settings: { + 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"], + browser_specific_settings: { 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..4569d005a5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js @@ -0,0 +1,79 @@ +/* -*- 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") + ); +}); + +add_task(function test_storage_onChanged_event_page() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_storage_change_event_page("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..dddaa65f67 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js @@ -0,0 +1,216 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + 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: { + browser_specific_settings: { 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: { + browser_specific_settings: { 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(); +}); + +add_task(async function test_manifest_not_found() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + + async background() { + const dummyListener = () => {}; + browser.storage.managed.onChanged.addListener(dummyListener); + browser.test.assertTrue( + browser.storage.managed.onChanged.hasListener(dummyListener), + "addListener works according to hasListener" + ); + browser.storage.managed.onChanged.removeListener(dummyListener); + + // We should get a warning for each registration. + browser.storage.managed.onChanged.addListener(() => {}); + browser.storage.managed.onChanged.addListener(() => {}); + browser.storage.managed.onChanged.addListener(() => {}); + + // Invoke the storage.managed API to make sure that we have made a + // round trip to the parent process and back. This is because event + // registration is async but we cannot await (bug 1300234). + 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(); + }, + }); + + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); + }); + const UNSUP_EVENT_WARNING = `attempting to use listener "storage.managed.onChanged", which is unimplemented`; + messages = messages.filter(msg => msg.message.includes(UNSUP_EVENT_WARNING)); + Assert.equal(messages.length, 4, "Expected msg for each addListener call"); +}); 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..2c8beb8b09 --- /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.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); + +// 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: { + browser_specific_settings: { + 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..27ad4ef2f4 --- /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"], + browser_specific_settings: { 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..bb7892d670 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js @@ -0,0 +1,109 @@ +/* -*- 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.enable_unsupported_legacy_implementation" + ), + }, + 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..e28af80d0a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.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"; + +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) + ); +}); + +add_task(function test_storage_onChanged_event_page() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_storage_change_event_page("sync") + ); +}); 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..db9dcd6ff6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js @@ -0,0 +1,2292 @@ +/* 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 { + ExtensionStorageSyncKinto: ExtensionStorageSync, + KintoStorageTestUtils: { + cleanUpForContext, + CollectionKeyEncryptionRemoteTransformer, + CryptoCollection, + idToKey, + keyToId, + KeyRingEncryptionRemoteTransformer, + }, +} = ChromeUtils.import("resource://gre/modules/ExtensionStorageSyncKinto.jsm"); +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 = Services.uuid; + return uuidgen.generateUUID().toString(); +} + +add_task(async function test_setup() { + await promiseStartupManager(); +}); + +add_task(async function test_single_initialization() { + // Check if we're calling openConnection too often. + const { FirefoxAdapter } = ChromeUtils.import( + "resource://services-common/kinto-storage-adapter.js" + ); + 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"], + browser_specific_settings: { 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"], + browser_specific_settings: { 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) + ); +}); + +add_task(function test_storage_onChanged_event_page() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_storage_change_event_page("sync") + ); +}); 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..9c9d8a2436 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js @@ -0,0 +1,118 @@ +/* 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 { + KintoStorageTestUtils: { EncryptionRemoteTransformer }, +} = ChromeUtils.import("resource://gre/modules/ExtensionStorageSyncKinto.jsm"); +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 = { + hmacKey: 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..0afe434d3f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js @@ -0,0 +1,364 @@ +/* -*- 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.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +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, + browser_specific_settings: { + gecko: { id: EXTENSION_ID1 }, + }, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + ...baseExtInfo, + manifest: { + ...baseManifest, + browser_specific_settings: { + 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() { + // 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..952d32dbfe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js @@ -0,0 +1,97 @@ +"use strict"; + +add_task(async function test_extension_page_tabs_create_reload_and_close() { + let events = []; + { + const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" + ); + 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..125f7eedd9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js @@ -0,0 +1,917 @@ +"use strict"; + +const { TelemetryArchive } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryArchive.sys.mjs" +); +const { TelemetryUtils } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryUtils.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +const { TelemetryArchiveTesting } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryArchiveTesting.sys.mjs" +); + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +// 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_bool_true() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet("telemetry.test.boolean_kind", true); + browser.test.notifyPass("scalar_set_bool_true"); + }, + doneSignal: "scalar_set_bool_true", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.boolean_kind", + true + ); + }); + + add_task(async function test_telemetry_scalar_set_bool_false() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet("telemetry.test.boolean_kind", false); + browser.test.notifyPass("scalar_set_bool_false"); + }, + doneSignal: "scalar_set_bool_false", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.boolean_kind", + false + ); + }); + + add_task(async function test_telemetry_scalar_unset_bool() { + Services.telemetry.clearScalars(); + TelemetryTestUtils.assertScalarUnset( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.boolean_kind" + ); + }); + + 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_zero() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet( + "telemetry.test.unsigned_int_kind", + 0 + ); + browser.test.notifyPass("scalar_set_zero"); + }, + doneSignal: "scalar_set_zero", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.unsigned_int_kind", + 0 + ); + }); + + 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("dynamic", 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( + "dynamic", + 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..94eed45d0a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js @@ -0,0 +1,62 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.blocklist.enabled", false); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +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: { + browser_specific_settings: { gecko: { id: TEST_ADDON_ID } }, + }, + background() { + browser.test.sendMessage("started_up"); + }, + }); + + await AddonTestUtils.promiseStartupManager(); + await extension.startup(); + await extension.awaitBackgroundStarted(); + 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.awaitBackgroundStarted(); + 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_theme_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js new file mode 100644 index 0000000000..4c3bf7b4d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_theme_experiments.js @@ -0,0 +1,109 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +// This test checks whether the theme experiments work for privileged static themes +// and are ignored for unprivileged static themes. +async function test_experiment_static_theme({ privileged }) { + let extensionManifest = { + theme: { + colors: {}, + images: {}, + properties: {}, + }, + theme_experiment: { + colors: {}, + images: {}, + properties: {}, + }, + }; + + const addonId = `${ + privileged ? "privileged" : "unprivileged" + }-static-theme@test-extension`; + const themeFiles = { + "manifest.json": { + name: "test theme", + version: "1.0", + manifest_version: 2, + browser_specific_settings: { + gecko: { id: addonId }, + }, + ...extensionManifest, + }, + }; + + const promiseThemeUpdated = TestUtils.topicObserved( + "lightweight-theme-styling-update" + ); + + let themeAddon; + const { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + let { addon } = await AddonTestUtils.promiseInstallXPI(themeFiles); + // Enable the newly installed static theme. + await addon.enable(); + themeAddon = addon; + }); + + const themeExperimentNotAllowed = { + message: /This extension is not allowed to run theme experiments/, + }; + AddonTestUtils.checkMessages(messages, { + forbidden: privileged ? [themeExperimentNotAllowed] : [], + expected: privileged ? [] : [themeExperimentNotAllowed], + }); + + if (privileged) { + // ext-theme.js Theme class constructor doesn't call Theme.prototype.load + // if the static theme includes theme_experiment but isn't allowed to. + info("Wait for theme updated observer service topic to be notified"); + const [topicSubject] = await promiseThemeUpdated; + let themeData = topicSubject.wrappedJSObject; + ok( + themeData.experiment, + "Expect theme experiment property to be defined in theme update data" + ); + } + + const policy = WebExtensionPolicy.getByID(themeAddon.id); + equal( + policy.extension.isPrivileged, + privileged, + `The static theme should be ${privileged ? "privileged" : "unprivileged"}` + ); + + await themeAddon.uninstall(); +} + +add_task(function test_privileged_theme() { + return test_experiment_static_theme({ privileged: true }); +}); + +add_task( + { + // Some builds (e.g. thunderbird) have experiments enabled by default. + pref_set: [["extensions.experiments.enabled", false]], + }, + function test_unprivileged_theme() { + return test_experiment_static_theme({ privileged: false }); + } +); 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..285ee07c57 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js @@ -0,0 +1,60 @@ +"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"]); + + 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..77eb0c89f7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js @@ -0,0 +1,211 @@ +"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", + "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"], + browser_specific_settings: { + 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, "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"], + browser_specific_settings: { 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"], + browser_specific_settings: { 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"], + browser_specific_settings: { 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..90d0f5865e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js @@ -0,0 +1,730 @@ +"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() { + for (let scriptMetadata of getTestCases(true)) { + await browser.userScripts.register({ + js: [{ file: "userscript.js" }], + runAt: "document_end", + matches: ["http://localhost/*/file_sample.html"], + scriptMetadata, + }); + } + + 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})()`, + 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"); + + const pageUrl = `${BASE_URL}/file_sample.html`; + info(`Load content page: ${pageUrl}`); + const page = await ExtensionTestUtils.loadContentPage(pageUrl); + + await extension.awaitMessage("apiscript:done"); + + await page.close(); + + 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(); +}); + +add_task(async function test_userScripts_are_unregistered_on_unload() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { + api_script: "api_script.js", + }, + }, + files: { + "userscript.js": "", + "extpage.html": `<!DOCTYPE html><script src="extpage.js"></script>`, + "extpage.js": async function extPage() { + await browser.userScripts.register({ + js: [{ file: "userscript.js" }], + matches: ["http://localhost/*/file_sample.html"], + }); + + browser.test.sendMessage("user-script-registered"); + }, + }, + }); + + await extension.startup(); + + equal( + // In order to read the `registeredContentScripts` map, we need to access + // the extension embedded in the `ExtensionWrapper` first. + extension.extension.registeredContentScripts.size, + 0, + "no user scripts registered yet" + ); + + const url = `moz-extension://${extension.uuid}/extpage.html`; + info(`loading extension page: ${url}`); + const page = await ExtensionTestUtils.loadContentPage(url); + + info("waiting for the user script to be registered"); + await extension.awaitMessage("user-script-registered"); + + equal( + extension.extension.registeredContentScripts.size, + 1, + "got registered user scripts in the extension content scripts map" + ); + + await page.close(); + + equal( + extension.extension.registeredContentScripts.size, + 0, + "user scripts unregistered from the extension content scripts map" + ); + + 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..67a18b0699 --- /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, + 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_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js new file mode 100644 index 0000000000..8310323dc1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_register.js @@ -0,0 +1,142 @@ +"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`; + +add_task(async function test_userscripts_register_cookieStoreId() { + async function background() { + const matches = ["<all_urls>"]; + + await browser.test.assertRejects( + browser.userScripts.register({ + js: [{ code: "" }], + matches, + cookieStoreId: "not_a_valid_cookieStoreId", + }), + /Invalid cookieStoreId/, + "userScript.register with an invalid cookieStoreId" + ); + + await browser.test.assertRejects( + browser.userScripts.register({ + js: [{ code: "" }], + matches, + cookieStoreId: "", + }), + /Invalid cookieStoreId/, + "userScripts.register with an invalid cookieStoreId" + ); + + let cookieStoreIdJSArray = [ + { + id: "firefox-container-1", + code: `document.body.textContent += "1"`, + }, + { + id: ["firefox-container-2", "firefox-container-3"], + code: `document.body.textContent += "2-3"`, + }, + { + id: "firefox-private", + code: `document.body.textContent += "private"`, + }, + { + id: "firefox-default", + code: `document.body.textContent += "default"`, + }, + ]; + + for (let { id, code } of cookieStoreIdJSArray) { + await browser.userScripts.register({ + js: [{ code }], + matches, + runAt: "document_end", + cookieStoreId: id, + }); + } + + await browser.contentScripts.register({ + js: [ + { + code: `browser.test.sendMessage("last-content-script");`, + }, + ], + matches, + runAt: "document_end", + }); + + browser.test.sendMessage("background_ready"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>"], + user_scripts: {}, + }, + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("background_ready"); + + registerCleanupFunction(() => extension.unload()); + + let testCases = [ + { + contentPageOptions: { userContextId: 0 }, + expectedTextContent: "default", + }, + { + contentPageOptions: { userContextId: 1 }, + expectedTextContent: "1", + }, + { + contentPageOptions: { userContextId: 2 }, + expectedTextContent: "2-3", + }, + { + contentPageOptions: { userContextId: 3 }, + expectedTextContent: "2-3", + }, + { + contentPageOptions: { userContextId: 4 }, + expectedTextContent: "", + }, + { + contentPageOptions: { privateBrowsing: true }, + expectedTextContent: "private", + }, + ]; + + for (let test of testCases) { + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html`, + test.contentPageOptions + ); + + await extension.awaitMessage("last-content-script"); + + let result = await contentPage.spawn(null, () => { + let textContent = this.content.document.body.textContent; + // Omit the default content from file_sample.html. + return textContent.replace("\n\nSample text\n\n\n\n", ""); + }); + + await contentPage.close(); + + equal( + result, + test.expectedTextContent, + `Expected textContent on content page` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js b/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js new file mode 100644 index 0000000000..1a41361491 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_wasm.js @@ -0,0 +1,135 @@ +/* -*- 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); + +// Common code snippet of background script in this test. +function background() { + globalThis.onsecuritypolicyviolation = event => { + browser.test.assertEq("wasm-eval", event.blockedURI, "blockedURI"); + if (browser.runtime.getManifest().version === 2) { + // In MV2, wasm eval violations are advisory only, as a transition tool. + browser.test.assertEq(event.disposition, "report", "MV2 disposition"); + } else { + browser.test.assertEq(event.disposition, "enforce", "MV3 disposition"); + } + browser.test.sendMessage("violated_csp", event.originalPolicy); + }; + try { + let wasm = new WebAssembly.Module( + new Uint8Array([0, 0x61, 0x73, 0x6d, 0x1, 0, 0, 0]) + ); + browser.test.assertEq(wasm.toString(), "[object WebAssembly.Module]"); + browser.test.sendMessage("result", "allowed"); + } catch (e) { + browser.test.assertEq( + "call to WebAssembly.Module() blocked by CSP", + e.message, + "Expected error when blocked" + ); + browser.test.sendMessage("result", "blocked"); + } +} + +add_task(async function test_wasm_v2() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 2, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "allowed"); + await extension.unload(); +}); + +add_task(async function test_wasm_v2_explicit() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 2, + content_security_policy: `object-src; script-src 'self' 'wasm-unsafe-eval'`, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "allowed"); + await extension.unload(); +}); + +// MV3 counterpart is test_wasm_v3_blocked_by_custom_csp. +add_task(async function test_wasm_v2_blocked_in_report_only_mode() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 2, + content_security_policy: `object-src; script-src 'self'`, + }, + }); + + await extension.startup(); + // "allowed" because wasm-unsafe-eval in MV2 is in report-only mode. + equal(await extension.awaitMessage("result"), "allowed"); + equal( + await extension.awaitMessage("violated_csp"), + "object-src 'none'; script-src 'self'" + ); + await extension.unload(); +}); + +add_task(async function test_wasm_v3_blocked_by_default() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "blocked"); + equal( + await extension.awaitMessage("violated_csp"), + "script-src 'self'; upgrade-insecure-requests", + "WASM usage violates default CSP in MV3" + ); + await extension.unload(); +}); + +// MV2 counterpart is test_wasm_v2_blocked_in_report_only_mode. +add_task(async function test_wasm_v3_blocked_by_custom_csp() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: "object-src; script-src 'self'", + }, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "blocked"); + equal( + await extension.awaitMessage("violated_csp"), + "object-src 'none'; script-src 'self'" + ); + await extension.unload(); +}); + +add_task(async function test_wasm_v3_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'wasm-unsafe-eval'; object-src 'self'`, + }, + }, + }); + + await extension.startup(); + equal(await extension.awaitMessage("result"), "allowed"); + await extension.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..4bdb24d247 --- /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: { + browser_specific_settings: { 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_containerIsolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js new file mode 100644 index 0000000000..53a23fc149 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_containerIsolation.js @@ -0,0 +1,59 @@ +"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() { + Services.prefs.setBoolPref("extensions.userContextIsolation.enabled", true); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertEq( + "firefox-container-2", + details.cookieStoreId, + "cookieStoreId is set" + ); + browser.test.notifyPass("allowed"); + }, + { urls: ["http://example.com/dummy"] } + ); + }, + }); + + Services.prefs.setCharPref( + "extensions.userContextIsolation.defaults.restricted", + "[1]" + ); + await extension.startup(); + + let restrictedPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 1 } + ); + + let allowedPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { + userContextId: 2, + } + ); + await extension.awaitFinish("allowed"); + + await extension.unload(); + await restrictedPage.close(); + await allowedPage.close(); + + Services.prefs.clearUserPref("extensions.userContextIsolation.enabled"); + Services.prefs.clearUserPref( + "extensions.userContextIsolation.defaults.restricted" + ); +}); 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_eventPage_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js new file mode 100644 index 0000000000..5c024c9a41 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_eventPage_StreamFilter.js @@ -0,0 +1,351 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const server = createHttpServer({ hosts: ["example.com"] }); + +let clearLastPendingRequest; + +server.registerPathHandler("/pending_request", (request, response) => { + response.processAsync(); + response.setHeader("Content-Length", "10000", false); + response.write("somedata\n"); + let intervalID = setInterval(() => response.write("continue\n"), 50); + + const clearPendingRequest = () => { + try { + clearInterval(intervalID); + response.finish(); + } catch (e) { + // This will throw, but we don't care at this point. + } + }; + + clearLastPendingRequest = clearPendingRequest; + registerCleanupFunction(clearPendingRequest); +}); + +server.registerPathHandler("/completed_request", (request, response) => { + response.write("somedata\n"); +}); + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +async function test_idletimeout_on_streamfilter({ + manifest_version, + expectResetIdle, + expectStreamFilterStop, + requestUrlPath, +}) { + const extension = ExtensionTestUtils.loadExtension({ + background: `(${async function(urlPath) { + browser.webRequest.onBeforeRequest.addListener( + request => { + browser.test.log(`webRequest request intercepted: ${request.url}`); + const filter = browser.webRequest.filterResponseData( + request.requestId + ); + const decoder = new TextDecoder("utf-8"); + const encoder = new TextEncoder(); + filter.onstart = () => { + browser.test.sendMessage("streamfilter:started"); + }; + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + }; + filter.onstop = () => { + filter.close(); + browser.test.sendMessage("streamfilter:stopped"); + }; + }, + { + urls: [`http://example.com/${urlPath}`], + }, + ["blocking"] + ); + browser.test.sendMessage("bg:ready"); + }})("${requestUrlPath}")`, + + useAddonManager: "temporary", + manifest: { + manifest_version, + background: manifest_version >= 3 ? {} : { persistent: false }, + granted_host_permissions: manifest_version >= 3, + permissions: + manifest_version >= 3 + ? ["webRequest", "webRequestBlocking", "webRequestFilterResponse"] + : ["webRequest", "webRequestBlocking"], + // host_permissions are merged with permissions on a MV2 test extension. + host_permissions: ["http://example.com/*"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg:ready"); + const { contextId } = extension.extension.backgroundContext; + notEqual(contextId, undefined, "Got a contextId for the background context"); + + info("Trigger a webRequest"); + const testURL = `http://example.com/${requestUrlPath}`; + const promiseRequestCompleted = ExtensionTestUtils.fetch( + "http://example.com/", + testURL + ).catch(err => { + // This request is expected to be aborted when cleared after the test is exiting, + // otherwise rethrow the error to trigger an explicit failure. + if (/The operation was aborted/.test(err.message)) { + info(`Test webRequest fetching "${testURL}" aborted`); + } else { + ok( + false, + `Unexpected rejection triggered by the test webRequest fetching "${testURL}": ${err.message}` + ); + throw err; + } + }); + + info("Wait for the stream filter to be started"); + await extension.awaitMessage("streamfilter:started"); + + if (expectStreamFilterStop) { + await extension.awaitMessage("streamfilter:stopped"); + } + + info("Terminate the background script (simulated idle timeout)"); + + if (expectResetIdle) { + const promiseResetIdle = promiseExtensionEvent( + extension, + "background-script-reset-idle" + ); + + clearHistograms(); + assertHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT); + assertKeyedHistogramEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID); + + await extension.terminateBackground(); + info("Wait for 'background-script-reset-idle' event to be emitted"); + await promiseResetIdle; + equal( + extension.extension.backgroundContext.contextId, + contextId, + "Initial background context is still available as expected" + ); + + assertHistogramCategoryNotEmpty(WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT, { + category: "reset_streamfilter", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + }); + + assertHistogramCategoryNotEmpty( + WEBEXT_EVENTPAGE_IDLE_RESULT_COUNT_BY_ADDONID, + { + keyed: true, + key: extension.id, + category: "reset_streamfilter", + categories: HISTOGRAM_EVENTPAGE_IDLE_RESULT_CATEGORIES, + } + ); + } else { + const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" + ); + const promiseProxyContextUnloaded = new Promise(resolve => { + function listener(evt, context) { + if (context.extension.id === extension.id) { + Management.off("proxy-context-unload", listener); + resolve(); + } + } + Management.on("proxy-context-unload", listener); + }); + await extension.terminateBackground(); + await promiseProxyContextUnloaded; + equal( + extension.extension.backgroundContext, + undefined, + "Initial background context should have been terminated as expected" + ); + } + + await extension.unload(); + clearLastPendingRequest(); + await promiseRequestCompleted; +} + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_idletimeout_on_active_streamfilter_mv2_eventpage() { + await test_idletimeout_on_streamfilter({ + manifest_version: 2, + requestUrlPath: "pending_request", + expectStreamFilterStop: false, + expectResetIdle: true, + }); + } +); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_idletimeout_on_active_streamfilter_mv3() { + await test_idletimeout_on_streamfilter({ + manifest_version: 3, + requestUrlPath: "pending_request", + expectStreamFilterStop: false, + expectResetIdle: true, + }); + } +); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_idletimeout_on_inactive_streamfilter_mv2_eventpage() { + await test_idletimeout_on_streamfilter({ + manifest_version: 2, + requestUrlPath: "completed_request", + expectStreamFilterStop: true, + expectResetIdle: false, + }); + } +); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_idletimeout_on_inactive_streamfilter_mv3() { + await test_idletimeout_on_streamfilter({ + manifest_version: 3, + requestUrlPath: "completed_request", + expectStreamFilterStop: true, + expectResetIdle: false, + }); + } +); + +async function test_create_new_streamfilter_while_suspending({ + manifest_version, +}) { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + let interceptedRequestId; + let resolvePendingWebRequest; + + browser.runtime.onSuspend.addListener(async () => { + await browser.test.assertThrows( + () => browser.webRequest.filterResponseData(interceptedRequestId), + /forbidden while background extension global is suspending/, + "Got the expected exception raised from filterResponseData calls while suspending" + ); + browser.test.sendMessage("suspend-listener"); + }); + + browser.runtime.onSuspendCanceled.addListener(async () => { + // Once onSuspendCanceled is emitted, filterResponseData + // is expected to don't throw. + const filter = browser.webRequest.filterResponseData( + interceptedRequestId + ); + resolvePendingWebRequest(); + filter.onstop = () => { + filter.disconnect(); + browser.test.sendMessage("suspend-canceled-listener"); + }; + }); + + browser.webRequest.onBeforeRequest.addListener( + request => { + browser.test.log(`webRequest request intercepted: ${request.url}`); + interceptedRequestId = request.requestId; + return new Promise(resolve => { + resolvePendingWebRequest = resolve; + browser.test.sendMessage("webrequest-listener:done"); + }); + }, + { + urls: [`http://example.com/completed_request`], + }, + ["blocking"] + ); + browser.test.sendMessage("bg:ready"); + }, + + useAddonManager: "temporary", + manifest: { + manifest_version, + background: manifest_version >= 3 ? {} : { persistent: false }, + granted_host_permissions: manifest_version >= 3, + permissions: + manifest_version >= 3 + ? ["webRequest", "webRequestBlocking", "webRequestFilterResponse"] + : ["webRequest", "webRequestBlocking"], + // host_permissions are merged with permissions on a MV2 test extension. + host_permissions: ["http://example.com/*"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg:ready"); + const { contextId } = extension.extension.backgroundContext; + notEqual(contextId, undefined, "Got a contextId for the background context"); + + info("Trigger a webRequest"); + ExtensionTestUtils.fetch( + "http://example.com/", + `http://example.com/completed_request` + ); + + info("Wait for the web request to be intercepted and suspended"); + await extension.awaitMessage("webrequest-listener:done"); + + info("Terminate the background script (simulated idle timeout)"); + + extension.terminateBackground({ disableResetIdleForTest: true }); + await extension.awaitMessage("suspend-listener"); + + info("Simulated idle timeout canceled"); + extension.extension.emit("background-script-reset-idle"); + await extension.awaitMessage("suspend-canceled-listener"); + + await extension.unload(); +} + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_error_creating_new_streamfilter_while_suspending_mv2_eventpage() { + await test_create_new_streamfilter_while_suspending({ + manifest_version: 2, + }); + } +); + +add_task( + { + pref_set: [["extensions.manifestV3.enabled", true]], + }, + async function test_error_creating_new_streamfilter_while_suspending_mv3() { + await test_create_new_streamfilter_while_suspending({ + manifest_version: 3, + }); + } +); 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..3eff2c8560 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js @@ -0,0 +1,611 @@ +"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"); +}); + +add_task(async function test_webRequestFilterResponse_permission() { + function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg !== "testFilterResponseData") { + browser.test.fail(`Unexpected test message: ${msg}`); + return; + } + + const [{ expectMissingPermissionError }] = args; + + if (expectMissingPermissionError) { + browser.test.assertThrows( + () => browser.webRequest.filterResponseData("fake-response-id"), + /Missing required "webRequestFilterResponse" permission/, + "Expected missing webRequestFilterResponse permission error" + ); + } else { + // Expect the generic error raised on invalid response id + // if the missing permission error isn't expected. + browser.test.assertTrue( + browser.webRequest.filterResponseData("fake-response-id"), + "Expected no missing webRequestFilterResponse permission error" + ); + } + + browser.test.notifyPass(); + }); + } + + info( + "Verify MV2 extension does not require webRequestFilterResponse permission" + ); + const extMV2 = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 2, + permissions: ["webRequest", "webRequestBlocking"], + }, + }); + + await extMV2.startup(); + extMV2.sendMessage("testFilterResponseData", { + expectMissingPermissionError: false, + }); + await extMV2.awaitFinish(); + await extMV2.unload(); + + info( + "Verify filterResponseData throws on MV3 extension without webRequestFilterResponse permission" + ); + const extMV3NoPerm = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + permissions: ["webRequest", "webRequestBlocking"], + }, + }); + + await extMV3NoPerm.startup(); + extMV3NoPerm.sendMessage("testFilterResponseData", { + expectMissingPermissionError: true, + }); + await extMV3NoPerm.awaitFinish(); + await extMV3NoPerm.unload(); + + info( + "Verify filterResponseData does not throw on MV3 extension without webRequestFilterResponse permission" + ); + const extMV3WithPerm = ExtensionTestUtils.loadExtension({ + background, + manifest: { + manifest_version: 3, + permissions: [ + "webRequest", + "webRequestBlocking", + "webRequestFilterResponse", + ], + }, + }); + + await extMV3WithPerm.startup(); + extMV3WithPerm.sendMessage("testFilterResponseData", { + expectMissingPermissionError: false, + }); + await extMV3WithPerm.awaitFinish(); + await extMV3WithPerm.unload(); +}); 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..fe3b6a8cf8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js @@ -0,0 +1,88 @@ +"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() { + 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"] + ); + }, + }); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.setBoolPref("dom.security.https_first_pbm", false); + } + + 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(); + + // Bug 1715801: Re-enable pbm portion on GeckoView + if (AppConstants.platform == "android") { + Services.prefs.clearUserPref("dom.security.https_first_pbm"); + } +}); 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..a1da0fe99b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js @@ -0,0 +1,545 @@ +/* -*- 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); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +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) { + 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"] + ); + }, +}; + +/** + * @typedef {object} ExpectedResourcesToLoad + * @property {object} img1_loaded image from a first party origin. + * @property {object} img3_loaded image from a third party origin. + * @property {object} script1_loaded script from a first party origin. + * @property {object} script3_loaded script from a third party origin. + * @property {object} [cspJSON] expected final document CSP (in JSON format, See dom/webidl/CSPDictionaries.webidl). + */ + +/** + * Test a combination of Content Security Policies against first/third party images/scripts. + * + * @param {object} opts + * @param {string} opts.site_csp The CSP to be sent by the site, or null. + * @param {string} opts.ext1_csp The CSP to be sent by the first extension, + * "" to remove the header, or null to not modify it. + * @param {string} opts.ext2_csp The CSP to be sent by the first extension, + * "" to remove the header, or null to not modify it. + * @param {ExpectedResourcesToLoad} opts.expect + * Object containing information which resources are expected to be loaded. + * @param {object} [opts.ext1_data] first test extension definition data (defaults to extensionData). + * @param {object} [opts.ext2_data] second test extension definition data (defaults to extensionData). + */ +async function test_csp({ + site_csp, + ext1_csp, + ext2_csp, + expect, + ext1_data = extensionData, + ext2_data = extensionData, +}) { + let extension1 = await ExtensionTestUtils.loadExtension(ext1_data); + let extension2 = await ExtensionTestUtils.loadExtension(ext2_data); + 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"); + let cspJSON = JSON.parse(this.content.document.cspJSON); + 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"), + cspJSON, + }; + }); + + await contentPage.close(); + await extension1.unload(); + await extension2.unload(); + + let action = { + true: "loaded", + false: "blocked", + }; + + info( + `test_csp: From "${site_csp}" to ${JSON.stringify( + ext1_csp + )} to ${JSON.stringify(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]}` + ); + + if (expect.cspJSON) { + Assert.deepEqual( + expect.cspJSON, + results.cspJSON["csp-policies"], + `Got the expected final CSP set on the content document` + ); + } +} + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +// Test that merging csp header on both mv2 and mv3 extensions +// (and combination of both). +add_task(async function test_webRequest_mergecsp() { + const testCases = [ + { + site_csp: "default-src *", + ext1_csp: "script-src 'none'", + ext2_csp: null, + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: false, + script3_loaded: false, + }, + }, + { + site_csp: null, + ext1_csp: "script-src 'none'", + ext2_csp: null, + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: false, + script3_loaded: false, + }, + }, + { + site_csp: "default-src *", + ext1_csp: "script-src 'none'", + ext2_csp: "img-src 'none'", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + }, + }, + { + site_csp: null, + ext1_csp: "script-src 'none'", + ext2_csp: "img-src 'none'", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + }, + }, + { + site_csp: "default-src *", + ext1_csp: "img-src example.com", + ext2_csp: "img-src example.org", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: true, + script3_loaded: true, + }, + }, + ]; + + const extMV2Data = { ...extensionData }; + const extMV3Data = { + ...extensionData, + useAddonManager: "temporary", + manifest: { + ...extensionData.manifest, + manifest_version: 3, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["*://example.net/*"], + granted_host_permissions: true, + }, + }; + + info("Run all test cases on ext1 MV2 and ext2 MV2"); + for (const testCase of testCases) { + await test_csp({ + ...testCase, + ext1_data: extMV2Data, + ext2_data: extMV2Data, + }); + } + + info("Run all test cases on ext1 MV3 and ext2 MV3"); + for (const testCase of testCases) { + await test_csp({ + ...testCase, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + } + + info("Run all test cases on ext1 MV3 and ext2 MV2"); + for (const testCase of testCases) { + await test_csp({ + ...testCase, + ext1_data: extMV3Data, + ext2_data: extMV2Data, + }); + } + + info("Run all test cases on ext1 MV2 and ext2 MV3"); + for (const testCase of testCases) { + await test_csp({ + ...testCase, + ext1_data: extMV2Data, + ext2_data: extMV3Data, + }); + } +}); + +add_task(async function test_remove_and_replace_csp_mv2() { + // CSP removed, CSP added. + await test_csp({ + site_csp: "img-src 'self'", + ext1_csp: "", + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); + + // CSP removed, CSP added. + await test_csp({ + site_csp: "default-src 'none'", + ext1_csp: "", + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); + + // CSP replaced - regression test for bug 1635781. + await test_csp({ + site_csp: "default-src 'none'", + ext1_csp: "img-src example.com", + ext2_csp: null, + expect: { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); + + // CSP unchanged, CSP replaced - regression test for bug 1635781. + await test_csp({ + site_csp: "default-src 'none'", + ext1_csp: null, + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); + + // CSP replaced, CSP removed. + await test_csp({ + site_csp: "default-src 'none'", + ext1_csp: "img-src example.com", + ext2_csp: "", + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }, + }); +}); + +// Test that fully replace the website csp header from an mv3 extension +// isn't allowed and it is considered a no-op. +add_task(async function test_remove_and_replace_csp_mv3() { + const extMV2Data = { ...extensionData }; + + const extMV3Data = { + ...extensionData, + useAddonManager: "temporary", + manifest: { + ...extensionData.manifest, + manifest_version: 3, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["*://example.net/*"], + granted_host_permissions: true, + }, + }; + + await test_csp({ + // site: CSP strict on images, lax on default and script src. + site_csp: "img-src 'self'", + // ext1: MV3 extension which return an empty CSP header (which is a no-op). + ext1_csp: "", + // ext2: MV3 extension which return a CSP header (which is expected to be merged). + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: true, + script3_loaded: true, + cspJSON: [ + { "img-src": ["'self'"], "report-only": false }, + { "img-src": ["http://example.com"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which return an empty CSP header (which is a no-op). + ext1_csp: "", + // ext2: MV3 extension which return a CSP header (which is expected to be merged). + ext2_csp: "img-src example.com", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["'none'"], "report-only": false }, + { "img-src": ["http://example.com"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which return a CSP header (which is expected to be merged and to + // not be able to make it less strict). + ext1_csp: "img-src example.com", + // ext2: MV3 extension which leaves the header unmodified. + ext2_csp: null, + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["'none'"], "report-only": false }, + { "img-src": ["http://example.com"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which merges additional directive into the site csp (and can't make + // it less strict). + ext1_csp: "img-src example.com", + // ext2: MV3 extension which merges an empty CSP header (which is a no-op, unlike with MV2). + ext2_csp: "", + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["'none'"], "report-only": false }, + { "img-src": ["http://example.com"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: lax CSP (which is expected to be made stricted by the ext1 extension). + site_csp: "default-src *", + // ext1: MV3 extension which wants to set a stricter CSP (expected to work fine with the MV3 extension) + ext1_csp: "default-src 'none'", + // ext2: MV3 extension which leaves it unchanged. + ext2_csp: null, + expect: { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["*"], "report-only": false }, + { "default-src": ["'none'"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension and tries to replace the strict site csp with this lax one + // (but as an MV3 extension that is going to be merged to the site csp and the + // resulting site CSP is expected to stay strict). + ext1_csp: "default-src *", + // ext2: MV3 extension which leaves it unchanged. + ext2_csp: null, + expect: { + // strict site csp merged with the lax one from ext1 stays strict. + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + cspJSON: [ + { "default-src": ["'none'"], "report-only": false }, + { "default-src": ["*"], "report-only": false }, + ], + }, + ext1_data: extMV3Data, + ext2_data: extMV3Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which return an empty CSP (expected to be a no-op for an MV3 extension). + ext1_csp: "", + // ext2: MV2 exension which wants to replace the site csp with a lax one (and still be allowed to + // because the empty one from the MV3 extension is expected to be a no-op). + ext2_csp: "default-src *", + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + cspJSON: [{ "default-src": ["*"], "report-only": false }], + }, + ext1_data: extMV3Data, + ext2_data: extMV2Data, + }); + + await test_csp({ + // site: CSP strict on default-src. + site_csp: "default-src 'none'", + // ext1: MV3 extension which return an empty CSP (which is expected to be a no-op). + ext1_csp: "", + // ext2: MV2 extension which also returns an empty CSP (which for an MV2 extension is expected + // to clear the CSP). + ext2_csp: "", + expect: { + img1_loaded: true, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + // Expect the resulting final document CSP to be empty (due to the MV2 extension clearing it). + cspJSON: [], + }, + ext1_data: extMV3Data, + ext2_data: extMV2Data, + }); +}); 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..02541ffd5d --- /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://testing-common/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_redirectProperty.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js new file mode 100644 index 0000000000..b0257bccd5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirectProperty.js @@ -0,0 +1,65 @@ +"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_redirect_property() { + function background(serverUrl) { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { redirectUrl: `${serverUrl}/dummy` }; + }, + { urls: ["*://localhost/*"] }, + ["blocking"] + ); + } + + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "redirect@test" } }, + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})("${gServerUrl}")`, + }); + await ext.startup(); + + let data = await new Promise(resolve => { + let ssm = Services.scriptSecurityManager; + + let channel = NetUtil.newChannel({ + uri: `${gServerUrl}/redirect`, + 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("redirectedByExtension"); + resolve({ id, url: request.QueryInterface(Ci.nsIChannel).URI.spec }); + }, + + onDataAvailable() {}, + }); + }); + + Assert.equal(`${gServerUrl}/dummy`, data.url, "request redirected"); + 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_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_restrictedHeaders.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js new file mode 100644 index 0000000000..6eb6a770f3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_restrictedHeaders.js @@ -0,0 +1,252 @@ +/* -*- 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); + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const server = createHttpServer({ + hosts: ["example.net"], +}); +server.registerPathHandler("/test/response-header", (req, res) => { + let headerName; + let headerValue; + if (req.queryString) { + let params = new URLSearchParams(req.queryString); + headerName = params.get("name"); + headerValue = params.get("value"); + res.setHeader(headerName, headerValue, false); + res.setHeader("test", `${headerName}=${headerValue}`, false); + } + res.write(""); +}); + +const extensionData = { + useAddonManager: "temporary", + background() { + const { manifest_version } = browser.runtime.getManifest(); + let headerToSet = undefined; + browser.test.onMessage.addListener(function(msg, arg) { + if (msg !== "header-to-set") { + return; + } + headerToSet = arg; + browser.test.sendMessage("header-to-set:done"); + }); + browser.webRequest.onHeadersReceived.addListener( + e => { + browser.test.log(`onHeadersReceived ${e.requestId} ${e.url}`); + if (headerToSet === undefined) { + browser.test.fail( + "extension called before headerToSet option was set" + ); + } + if (typeof headerToSet?.name == "string") { + const existingHeader = e.responseHeaders.filter( + i => i.name.toLowerCase() === headerToSet.name + )[0]; + e.responseHeaders = e.responseHeaders.filter( + i => i.name.toLowerCase() != headerToSet.name + ); + // Omit the header if the value isn't set, change the header otherwise. + if (headerToSet.value != null) { + e.responseHeaders.push({ + name: headerToSet.name, + value: headerToSet.value, + }); + } + browser.test.log( + `Test Extension MV${manifest_version} (${browser.runtime.id}) sets responseHeader: "${headerToSet.name}"="${headerToSet.value}" (was originally set to "${existingHeader?.value})"` + ); + } + return { responseHeaders: e.responseHeaders }; + }, + { urls: ["*://example.net/test/*"] }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onCompleted.addListener( + e => { + browser.test.log(`onCompletedReceived ${e.requestId} ${e.url}`); + const responseHeaders = e.responseHeaders.filter( + i => i.name.toLowerCase() === headerToSet.name + ); + + browser.test.sendMessage( + "on-completed:response-headers", + responseHeaders + ); + }, + { urls: ["*://example.net/test/*"] }, + ["responseHeaders"] + ); + browser.test.sendMessage("bgpage:ready"); + }, +}; + +const extDataMV2 = { + ...extensionData, + manifest: { + manifest_version: 2, + permissions: ["webRequest", "webRequestBlocking", "*://example.net/test/*"], + }, +}; + +const extDataMV3 = { + ...extensionData, + manifest: { + manifest_version: 3, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["*://example.net/test/*"], + granted_host_permissions: true, + }, +}; + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +async function test_restricted_response_headers_changes({ + firstExtData, + secondExtData, + headerName, + firstExtHeaderChange, + secondExtHeaderChange, + siteHeaderValue, + expectedHeaderValue, +}) { + const ext1 = ExtensionTestUtils.loadExtension(firstExtData); + const ext2 = secondExtData && ExtensionTestUtils.loadExtension(secondExtData); + + await ext1.startup(); + await ext1.awaitMessage("bgpage:ready"); + + await ext2?.startup(); + await ext2?.awaitMessage("bgpage:ready"); + + ext1.sendMessage("header-to-set", { + name: headerName, + value: firstExtHeaderChange, + }); + await ext1.awaitMessage("header-to-set:done"); + ext2?.sendMessage("header-to-set", { + name: headerName, + value: secondExtHeaderChange, + }); + await ext2?.awaitMessage("header-to-set:done"); + + if (siteHeaderValue) { + await ExtensionTestUtils.fetch( + "http://example.net/", + `http://example.net/test/response-header?name=${headerName}&value=${siteHeaderValue}` + ); + } else { + await ExtensionTestUtils.fetch( + "http://example.net/", + "http://example.net/test/response-header" + ); + } + + const [finalSiteHeaders] = await Promise.all([ + ext1.awaitMessage("on-completed:response-headers"), + ext2?.awaitMessage("on-completed:response-headers"), + ]); + + Assert.deepEqual( + finalSiteHeaders, + expectedHeaderValue + ? [{ name: headerName, value: expectedHeaderValue }] + : [], + "Got the expected response header" + ); + + await ext1.unload(); + await ext2?.unload(); +} + +add_task(async function test_changes_to_restricted_response_headers() { + const testCases = [ + { + headerName: "cross-origin-embedder-policy", + siteHeaderValue: "require-corp", + firstExtHeaderChange: "credentialless", + secondExtHeaderChange: "unsafe-none", + }, + { + headerName: "cross-origin-opener-policy", + siteHeaderValue: "same-origin", + firstExtHeaderChange: "same-origin-allow-popups", + secondExtHeaderChange: "unsafe-none", + }, + { + headerName: "cross-origin-resource-policy", + siteHeaderValue: "same-origin", + firstExtHeaderChange: "same-site", + secondExtHeaderChange: "cross-origin", + }, + { + headerName: "x-frame-options", + siteHeaderValue: "deny", + firstExtHeaderChange: "sameorigin", + secondExtHeaderChange: "allow-from=http://example.com", + }, + { + headerName: "access-control-allow-credentials", + siteHeaderValue: "true", + firstExtHeaderChange: "false", + secondExtHeaderChange: "false", + }, + { + headerName: "access-control-allow-methods", + siteHeaderValue: "*", + firstExtHeaderChange: "", + secondExtHeaderChange: "GET", + }, + ]; + + for (const testCase of testCases) { + info( + `Test MV3 extension disallowed to change restricted header if already set by the website: "${testCase.headerName}"="${testCase.siteHeaderValue}` + ); + await test_restricted_response_headers_changes({ + ...testCase, + firstExtData: extDataMV3, + // Expect the value set by the server to be preserved. + expectedHeaderValue: testCase.siteHeaderValue, + }); + } + + for (const testCase of testCases) { + info( + `Test MV3 extension disallowed to change restricted header also if not set by the website: "${testCase.headerName}` + ); + await test_restricted_response_headers_changes({ + ...testCase, + siteHeaderValue: null, + firstExtData: extDataMV3, + // Expect the value set by the server to be preserved. + expectedHeaderValue: null, + }); + } + + for (const testCase of testCases) { + info( + `Test MV2 extension allowed to change restricted header if already set by the website: ${JSON.stringify( + testCase.siteHeader + )}` + ); + await test_restricted_response_headers_changes({ + ...testCase, + firstExtData: extDataMV3, + secondExtData: extDataMV2, + // Expect the value set by the server to be preserved. + expectedHeaderValue: testCase.secondExtHeaderChange, + }); + } +}); 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..f9cc762cf0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js @@ -0,0 +1,756 @@ +"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 { + promiseShutdownManager, + promiseStartupManager, + promiseRestartManager, +} = 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); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-script-event", "start-background-script"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +/** + * That that we get the expected events + * + * @param {Extension} extension + * @param {Map} events + * @param {object} expect + * @param {boolean} expect.background delayed startup event expected + * @param {boolean} expect.started background has already started + * @param {boolean} expect.delayedStart startup is delayed, notify start and + * expect the starting event + * @param {boolean} expect.request wait for the request event + */ +async function testPersistentRequestStartup(extension, events, expect = {}) { + equal( + events.get("background-script-event"), + !!expect.background, + "Should have gotten a background script event" + ); + equal( + events.get("start-background-script"), + !!expect.started, + "Background script should be started" + ); + + if (!expect.started) { + AddonTestUtils.notifyEarlyStartup(); + await ExtensionParent.browserPaintedPromise; + + equal( + events.get("start-background-script"), + !!expect.delayedStart, + "Should have gotten start-background-script event" + ); + } + + if (expect.request) { + await extension.awaitMessage("got-request"); + ok(true, "Background page loaded and received webRequest event"); + } +} + +// Test that a non-blocking listener does not start the background on +// startup, but that it does work after startup. +add_task(async function test_nonblocking() { + 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"] } + ); + browser.test.sendMessage("ready"); + }, + }); + + // First install runs background immediately, this sets persistent listeners + await extension.startup(); + await extension.awaitMessage("ready"); + + // Restart to get APP_STARTUP, the background should not start + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + // Test an early startup event + 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, + }); + + AddonTestUtils.notifyLateStartup(); + await extension.awaitMessage("ready"); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + // Test an event after startup + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: false, + started: true, + request: true, + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +// Test that a non-blocking listener does not start the background on +// startup, but that it does work after startup. +add_task(async function test_eventpage_nonblocking() { + Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + await promiseStartupManager(); + + let id = "event-nonblocking@test"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id } }, + permissions: ["webRequest", "http://example.com/"], + background: { persistent: false }, + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + }, + }); + + // First install runs background immediately, this sets persistent listeners + await extension.startup(); + + // Restart to get APP_STARTUP, the background should not start + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + // Test an early startup event + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events); + + await AddonTestUtils.notifyLateStartup(); + // After late startup, event page listeners should be primed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: true, + }); + + // We should not have seen any events yet. + await testPersistentRequestStartup(extension, events); + + // Test an event after startup + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + // Now the event page should be started and we'll see the request. + await testPersistentRequestStartup(extension, events, { + background: true, + started: true, + request: true, + }); + + await extension.unload(); + + await promiseShutdownManager(); + Services.prefs.setBoolPref("extensions.eventPages.enabled", false); +}); + +// 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_persistent_blocking() { + 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"] + ); + }, + }); + + await extension.startup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + await promiseRestartManager({ lateStartup: false }); + await extension.awaitStartup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: true, + }); + + 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, + }); + + AddonTestUtils.notifyLateStartup(); + + 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", + browser_specific_settings: { gecko: { id } }, + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + }, + }; + let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + + let extension = ExtensionTestUtils.expectExtension(id); + await AddonTestUtils.manuallyInstall(xpi); + await promiseStartupManager(); + await extension.awaitStartup(); + // Sideload install does not prime listeners + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + + 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", + "webRequestBlocking", + ]; + xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + await AddonTestUtils.manuallyInstall(xpi); + + await promiseStartupManager(); + await extension.awaitStartup(); + // Upgrades start the background when the extension is loaded, so + // primed listeners are cleared already and background events are + // already completed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + persisted: 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", + browser_specific_settings: { gecko: { id } }, + permissions: [ + "webRequest", + "webRequestBlocking", + "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"] }, + ["blocking"] + ); + }, + }; + 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; + assertPersistentListeners( + { extension: extv1 }, + "webRequest", + "onBeforeRequest", + { + primed: false, + } + ); + + // 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 + let extension = ExtensionTestUtils.expectExtension(id); + await promiseStartupManager(); + await extension.awaitStartup(); + // Upgrades start the background when the extension is loaded, so + // primed listeners are cleared already and background events are + // already completed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + persisted: 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", + browser_specific_settings: { + gecko: { id, update_url: `http://example.com/test_update.json` }, + }, + permissions: ["http://example.com/"], + optional_permissions: ["webRequest", "webRequestBlocking"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + browser.webRequest.onSendHeaders.addListener( + details => { + browser.test.sendMessage("got-sendheaders"); + }, + { 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", + "webRequestBlocking", + "http://example.com/", + ]; + delete extensionData.manifest.optional_permissions; + extensionData.background = function() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.sendMessage("got-beforesendheaders"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + browser.webRequest.onSendHeaders.addListener( + details => { + browser.test.sendMessage("got-sendheaders"); + }, + { 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 promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + }); + assertPersistentListeners(extension, "webRequest", "onBeforeSendHeaders", { + primed: false, + }); + assertPersistentListeners(extension, "webRequest", "onSendHeaders", { + primed: false, + }); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + await extension.awaitMessage("got-beforesendheaders"); + await extension.awaitMessage("got-sendheaders"); + 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(); + await extension.awaitStartup(); + + // Upgrades start the background when the extension is loaded, so + // primed listeners are cleared already and background events are + // already completed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + persisted: true, + }); + // this was removed in the upgrade background, should not be persisted. + assertPersistentListeners(extension, "webRequest", "onBeforeSendHeaders", { + primed: false, + persisted: false, + }); + assertPersistentListeners(extension, "webRequest", "onSendHeaders", { + primed: false, + persisted: 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() { + AddonManager.checkUpdateSecurity = false; + 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", + browser_specific_settings: { + 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", + browser_specific_settings: { + gecko: { id, update_url: `http://example.com/test_remove.json` }, + }, + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + // 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({ lateStartup: false }); + await extension.awaitStartup(); + await extension.awaitMessage("loaded"); + + // Upgrades start the background when the extension is loaded, so + // primed listeners are cleared already and background events are + // already completed. + assertPersistentListeners(extension, "webRequest", "onBeforeRequest", { + primed: false, + persisted: false, + }); + + await extension.unload(); + await promiseShutdownManager(); + AddonManager.checkUpdateSecurity = true; +}); 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..89b916424d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js @@ -0,0 +1,79 @@ +"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")); + +// 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..a2c57767ea --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js @@ -0,0 +1,290 @@ +"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; + }); + { + 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..9451fd1215 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js @@ -0,0 +1,43 @@ +"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() { + // The startupCache is removed whenever the buildid changes by code that runs + // during Firefox startup but not during xpcshell startup, remove it by hand + // before running this test to avoid failures with --conditioned-profile + let file = PathUtils.join( + Services.dirsvc.get("ProfLD", Ci.nsIFile).path, + "startupCache", + "webext.sc.lz4" + ); + await IOUtils.remove(file, { ignoreAbsent: true }); + + // 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_webSocket.js b/toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js new file mode 100644 index 0000000000..d5aab3c7f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webSocket.js @@ -0,0 +1,162 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = `http://example.com`; +const pageURL = `${BASE_URL}/plain.html`; + +server.registerPathHandler("/plain.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + response.setHeader("Content-Security-Policy", "upgrade-insecure-requests;"); + response.write("<!DOCTYPE html><html></html>"); +}); + +async function testWebSocketInFrameUpgraded() { + const frame = document.createElement("iframe"); + frame.src = browser.runtime.getURL("frame.html"); + document.documentElement.appendChild(frame); +} + +// testIframe = true: open WebSocket from iframe (original test case). +// testIframe = false: open WebSocket from content script. +async function test_webSocket({ + manifest_version, + useIframe, + content_security_policy, + expectUpgrade, +}) { + let web_accessible_resources = + manifest_version == 2 + ? ["frame.html"] + : [{ resources: ["frame.html"], matches: ["*://example.com/*"] }]; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + permissions: ["webRequest", "webRequestBlocking"], + host_permissions: ["<all_urls>"], + granted_host_permissions: true, + web_accessible_resources, + content_security_policy, + content_scripts: [ + { + matches: ["http://*/plain.html"], + run_at: "document_idle", + js: [useIframe ? "content_script.js" : "load_WebSocket.js"], + }, + ], + }, + temporarilyInstalled: true, + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + let header = details.requestHeaders.find(h => h.name === "Origin"); + browser.test.sendMessage("ws_request", { + ws_scheme: new URL(details.url).protocol, + originHeader: header?.value, + }); + }, + { urls: ["wss://example.com/*", "ws://example.com/*"] }, + ["requestHeaders", "blocking"] + ); + }, + files: { + "frame.html": ` +<html> + <head> + <meta charset="utf-8"/> + <script src="load_WebSocket.js"></script> + </head> + <body> + </body> +</html> + `, + "load_WebSocket.js": `new WebSocket("ws://example.com/ws_dummy");`, + "content_script.js": ` + (${testWebSocketInFrameUpgraded})() + `, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + let { ws_scheme, originHeader } = await extension.awaitMessage("ws_request"); + + if (expectUpgrade) { + Assert.equal(ws_scheme, "wss:", "ws:-request should have been upgraded"); + } else { + Assert.equal(ws_scheme, "ws:", "ws:-request should not have been upgraded"); + } + + if (useIframe) { + Assert.equal( + originHeader, + `moz-extension://${extension.uuid}`, + "Origin header of WebSocket request from extension page" + ); + } else { + Assert.equal( + originHeader, + manifest_version == 2 ? "null" : "http://example.com", + "Origin header of WebSocket request from content script" + ); + } + await contentPage.close(); + await extension.unload(); +} + +// Page CSP does not affect extension iframes. +add_task(async function test_webSocket_upgrade_iframe_mv2() { + await test_webSocket({ + manifest_version: 2, + useIframe: true, + expectUpgrade: false, + }); +}); + +// Page CSP does not affect extension iframes, however upgrade-insecure-requests causes this +// request to be upgraded in the iframe. +add_task(async function test_webSocket_upgrade_iframe_mv3() { + await test_webSocket({ + manifest_version: 3, + useIframe: true, + expectUpgrade: true, + }); +}); + +// Test that removing upgrade-insecure-requests allows http request in the iframe. +add_task(async function test_webSocket_noupgrade_iframe_mv3() { + let content_security_policy = { + extension_pages: `script-src 'self'`, + }; + await test_webSocket({ + manifest_version: 3, + content_security_policy, + useIframe: true, + expectUpgrade: false, + }); +}); + +// Page CSP does not affect MV2 in the content script. +add_task(async function test_webSocket_upgrade_in_contentscript_mv2() { + await test_webSocket({ + manifest_version: 2, + useIframe: false, + expectUpgrade: false, + }); +}); + +// Page CSP affects MV3 in the content script. +add_task(async function test_webSocket_upgrade_in_contentscript_mv3() { + await test_webSocket({ + manifest_version: 3, + useIframe: false, + expectUpgrade: true, + }); +}); 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..ca06209ffa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js @@ -0,0 +1,147 @@ +"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.runtime.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.runtime.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, () => { + 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_web_accessible_resources_matches.js b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js new file mode 100644 index 0000000000..ca7306bdef --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources_matches.js @@ -0,0 +1,468 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +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; + +add_task(async function test_web_accessible_resources_matching() { + let extension = await ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + }, + ], + }, + }); + + await Assert.rejects( + extension.startup(), + /web_accessible_resources requires one of "matches" or "extension_ids"/, + "web_accessible_resources object format incorrect" + ); + + extension = await ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + matches: ["http://example.com/data/*"], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with matches loads"); + await extension.unload(); + + extension = await ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + extension_ids: ["foo@mochitest"], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with extensions loads"); + await extension.unload(); + + extension = await ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + matches: ["http://example.com/data/*"], + extension_ids: ["foo@mochitest"], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with matches and extensions loads"); + await extension.unload(); + + extension = await ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + extension_ids: [], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with empty extensions loads"); + await extension.unload(); + + extension = await ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/accessible.html"], + matches: ["http://example.com/data/*"], + extension_ids: [], + }, + ], + }, + }); + + await extension.startup(); + ok(true, "web_accessible_resources with matches and empty extensions loads"); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources() { + async function contentScript() { + let canLoad = window.location.href.startsWith("http://example.com"); + let urls = [ + { + name: "iframe", + path: "accessible.html", + shouldLoad: canLoad, + }, + { + name: "iframe", + path: "inaccessible.html", + shouldLoad: false, + }, + { + name: "img", + path: "image.png", + shouldLoad: true, + }, + { + name: "script", + path: "script.js", + shouldLoad: canLoad, + }, + ]; + + function test_element_src(name, url) { + return new Promise(resolve => { + let elem = document.createElement(name); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + elem.wrappedJSObject.setAttribute("src", url); + elem.addEventListener( + "load", + () => { + resolve(true); + }, + { once: true } + ); + elem.addEventListener( + "error", + () => { + resolve(false); + }, + { once: true } + ); + document.body.appendChild(elem); + }); + } + for (let test of urls) { + let loaded = await test_element_src( + test.name, + browser.runtime.getURL(test.path) + ); + browser.test.assertEq( + loaded, + test.shouldLoad, + `resource loaded ${test.path} in ${window.location.href}` + ); + } + browser.test.sendMessage("complete"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + content_scripts: [ + { + matches: ["http://example.com/data/*", "http://example.org/data/*"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + host_permissions: ["http://example.com/*", "http://example.org/*"], + granted_host_permissions: true, + + web_accessible_resources: [ + { + resources: ["/accessible.html", "/script.js"], + matches: ["http://example.com/data/*"], + }, + { + resources: ["/image.png"], + matches: ["<all_urls>"], + }, + ], + }, + temporarilyInstalled: true, + + files: { + "content_script.js": contentScript, + + "accessible.html": `<html><head> + <meta charset="utf-8"> + </head></html>`, + + "inaccessible.html": `<html><head> + <meta charset="utf-8"> + </head></html>`, + + "image.png": IMAGE_ARRAYBUFFER, + "script.js": () => { + // empty script + }, + }, + }); + + await extension.startup(); + + let page = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/" + ); + + await extension.awaitMessage("complete"); + await page.close(); + + // None of the test resources are loadable in example.org + page = await ExtensionTestUtils.loadContentPage("http://example.org/data/"); + + await extension.awaitMessage("complete"); + + await page.close(); + await extension.unload(); +}); + +async function pageScript() { + function test_element_src(data) { + return new Promise(resolve => { + let elem = document.createElement(data.elem); + let elemContext = + data.content_context && elem.wrappedJSObject + ? elem.wrappedJSObject + : elem; + elemContext.setAttribute("src", data.url); + elem.addEventListener( + "load", + () => { + browser.test.log(`got load event for ${data.url}`); + resolve(true); + }, + { once: true } + ); + elem.addEventListener( + "error", + () => { + browser.test.log(`got error event for ${data.url}`); + resolve(false); + }, + { once: true } + ); + document.body.appendChild(elem); + }); + } + browser.test.onMessage.addListener(async msg => { + browser.test.log(`testing ${JSON.stringify(msg)}`); + let loaded = await test_element_src(msg); + browser.test.assertEq(loaded, msg.shouldLoad, `${msg.name} loaded`); + browser.test.sendMessage("web-accessible-resources"); + }); + browser.test.sendMessage("page-loaded"); +} + +add_task(async function test_web_accessible_resources_extensions() { + let other = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "other@mochitest" } }, + }, + files: { + "page.js": pageScript, + + "page.html": `<html><head> + <meta charset="utf-8"> + <script src="page.js"></script> + </head></html>`, + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + browser_specific_settings: { gecko: { id: "this@mochitest" } }, + web_accessible_resources: [ + { + resources: ["/image.png"], + extension_ids: ["other@mochitest"], + }, + ], + }, + + files: { + "image.png": IMAGE_ARRAYBUFFER, + "inaccessible.png": IMAGE_ARRAYBUFFER, + "page.js": pageScript, + + "page.html": `<html><head> + <meta charset="utf-8"> + <script src="page.js"></script> + </head></html>`, + }, + }); + + await extension.startup(); + let extensionUrl = `moz-extension://${extension.uuid}/`; + + await other.startup(); + let pageUrl = `moz-extension://${other.uuid}/page.html`; + + let page = await ExtensionTestUtils.loadContentPage(pageUrl); + await other.awaitMessage("page-loaded"); + + other.sendMessage({ + name: "accessible resource", + elem: "img", + url: `${extensionUrl}image.png`, + shouldLoad: true, + }); + await other.awaitMessage("web-accessible-resources"); + + other.sendMessage({ + name: "inaccessible resource", + elem: "img", + url: `${extensionUrl}inaccessible.png`, + shouldLoad: false, + }); + await other.awaitMessage("web-accessible-resources"); + + await page.close(); + + // test that the extension may load it's own web accessible resource + page = await ExtensionTestUtils.loadContentPage(`${extensionUrl}page.html`); + await extension.awaitMessage("page-loaded"); + + extension.sendMessage({ + name: "accessible resource", + elem: "img", + url: `${extensionUrl}image.png`, + shouldLoad: true, + }); + await extension.awaitMessage("web-accessible-resources"); + + await page.close(); + await extension.unload(); + await other.unload(); +}); + +// test that a web page not in matches cannot load the resource +add_task(async function test_web_accessible_resources_inaccessible() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + manifest: { + manifest_version: 3, + browser_specific_settings: { gecko: { id: "web@mochitest" } }, + content_scripts: [ + { + matches: ["http://example.com/data/*"], + js: ["page.js"], + run_at: "document_idle", + }, + ], + web_accessible_resources: [ + { + resources: ["/image.png"], + extension_ids: ["some_other_ext@mochitest"], + }, + ], + host_permissions: ["*://example.com/*"], + granted_host_permissions: true, + }, + + files: { + "image.png": IMAGE_ARRAYBUFFER, + "page.js": pageScript, + + "page.html": `<html><head> + <meta charset="utf-8"> + <script src="page.js"></script> + </head></html>`, + }, + }); + + await extension.startup(); + let extensionUrl = `moz-extension://${extension.uuid}/`; + let page = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/" + ); + await extension.awaitMessage("page-loaded"); + + extension.sendMessage({ + name: "cannot access resource", + elem: "img", + url: `${extensionUrl}image.png`, + content_context: true, + shouldLoad: false, + }); + await extension.awaitMessage("web-accessible-resources"); + + await page.close(); + await extension.unload(); +}); + +add_task(async function test_web_accessible_resources_empty_extension_ids() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + web_accessible_resources: [ + { + resources: ["/file.txt"], + matches: ["http://example.com/data/*"], + extension_ids: [], + }, + ], + }, + + files: { + "file.txt": "some content", + }, + }); + let secondExtension = ExtensionTestUtils.loadExtension({ + files: { + "page.html": "", + }, + }); + + await extension.startup(); + await secondExtension.startup(); + + const fileURL = extension.extension.baseURI.resolve("file.txt"); + Assert.equal( + await ExtensionTestUtils.fetch("http://example.com/data/", fileURL), + "some content", + "expected access to the extension's resource" + ); + + // We need to use `try/catch` because `Assert.rejects` does not seem to catch + // the error correctly and the task fails because of an uncaught exception. + // This is likely due to how errors are propagated somehow. + try { + await ExtensionTestUtils.fetch( + secondExtension.extension.baseURI.resolve("page.html"), + fileURL + ); + ok(false, "expected an error to be thrown"); + } catch (e) { + Assert.equal( + e?.message, + "NetworkError when attempting to fetch resource.", + "expected a network error" + ); + } + + await extension.unload(); + await secondExtension.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..0728946817 --- /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.runtime.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.runtime.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_ext_xhr_cors.js b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js new file mode 100644 index 0000000000..983fe1c542 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_cors.js @@ -0,0 +1,223 @@ +"use strict"; + +// The purpose of this test is to show that the XMLHttpRequest API behaves +// similarly in MV2 and MV3, except for intentional differences related to +// permission handling. + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ + hosts: ["example.com", "example.net", "example.org"], +}); +server.registerPathHandler("/dummy", (req, res) => { + res.setStatusLine(req.httpVersion, 200, "OK"); + res.setHeader("Content-Type", "text/html; charset=utf-8"); + + // A very strict CSP. + res.setHeader( + "Content-Security-Policy", + "default-src; script-src 'nonce-kindasecret'; connect-src http:" + ); + + res.write( + `<script id="id_of_some_element" nonce="kindasecret"> + // Clobber XMLHttpRequest API to allow us to verify that the page's value + // for it does not affect the XMLHttpRequest API in the content script. + window.XMLHttpRequest = "This is not XMLHttpRequest"; + </script> + ` + ); +}); +server.registerPathHandler("/dummy.json", (req, res) => { + res.write(`{"mykey": "kvalue"}`); +}); +server.registerPathHandler("/nocors", (req, res) => { + res.write("no cors"); +}); +server.registerPathHandler("/cors-enabled", (req, res) => { + res.setHeader("Access-Control-Allow-Origin", "http://example.com"); + res.write("cors_response"); +}); +server.registerPathHandler("/return-origin", (req, res) => { + res.setHeader("Content-Type", "text/plain"); + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "*"); + res.write(req.hasHeader("Origin") ? req.getHeader("Origin") : "undefined"); +}); + +// We just need to test XHR; fetch is already covered by test_ext_secfetch.js. +async function test_xhr({ manifest_version }) { + async function contentScript(manifest_version) { + function runXHR(url, extraXHRProps, method = "GET") { + return new Promise(resolve => { + let x = new XMLHttpRequest(); + x.open(method, url); + Object.assign(x, extraXHRProps); + x.onloadend = () => resolve(x); + x.send(); + }); + } + async function checkXHR({ + description, + url, + extraXHRProps, + method, + expected, + }) { + let { status, response } = expected; + let x = await runXHR(url, extraXHRProps, method); + browser.test.assertEq(status, x.status, `${description} - status`); + browser.test.assertEq(response, x.response, `${description} - body`); + } + + await checkXHR({ + description: "Same-origin", + url: "http://example.com/nocors", + expected: { status: 200, response: "no cors" }, + }); + + await checkXHR({ + description: "Cross-origin without CORS", + url: "http://example.org/nocors", + expected: { status: 0, response: "" }, + }); + + await checkXHR({ + description: "Cross-origin with CORS", + url: "http://example.org/cors-enabled", + expected: + manifest_version === 2 + ? // Bug 1605197: MV2 cannot fall back to CORS. + { status: 0, response: "" } + : { status: 200, response: "cors_response" }, + }); + + // MV2 allowed cross-origin requests in content scripts with host + // permissions, but MV3 does not. + await checkXHR({ + description: "Cross-origin without CORS, with permission", + url: "http://example.net/nocors", + expected: + manifest_version === 2 + ? { status: 200, response: "no cors" } + : { status: 0, response: "" }, + }); + + await checkXHR({ + description: "Cross-origin with CORS (and permission)", + url: "http://example.net/cors-enabled", + expected: { status: 200, response: "cors_response" }, + }); + + // MV2 has a XMLHttpRequest instance specific to the sandbox. + // MV3 uses the page's XMLHttpRequest and currently enforces the page's CSP. + // TODO bug 1766813: Enforce content script CSP instead. + await checkXHR({ + description: "data:-URL while page blocks data: via CSP", + url: "data:,data-url", + expected: + // Should be "data-url" in MV3 too. + manifest_version === 2 + ? { status: 200, response: "data-url" } + : { status: 0, response: "" }, + }); + + { + let x = await runXHR("http://example.com/dummy.json", { + responseType: "json", + }); + browser.test.assertTrue(x.response instanceof Object, "is JSON object"); + browser.test.assertEq(x.response.mykey, "kvalue", "can read parsed JSON"); + } + + { + let x = await runXHR("http://example.com/dummy", { + responseType: "document", + }); + browser.test.assertTrue(HTMLDocument.isInstance(x.response), "is doc"); + browser.test.assertTrue( + x.response.querySelector("#id_of_some_element"), + "got parsed document" + ); + } + + await checkXHR({ + description: "Same-origin Origin header", + url: "http://example.com/return-origin", + expected: { status: 200, response: "undefined" }, + }); + + await checkXHR({ + description: "Same-origin POST Origin header", + url: "http://example.com/return-origin", + method: "POST", + expected: + manifest_version === 2 + ? { status: 200, response: "undefined" } + : { status: 200, response: "http://example.com" }, + }); + + await checkXHR({ + description: "Cross-origin (CORS) Origin header", + url: "http://example.org/return-origin", + expected: + manifest_version === 2 + ? // Bug 1605197: MV2 cannot fall back to CORS. + { status: 0, response: "" } + : { status: 200, response: "http://example.com" }, + }); + + await checkXHR({ + description: "Cross-origin (CORS) POST Origin header", + url: "http://example.org/return-origin", + method: "POST", + expected: + manifest_version === 2 + ? // Bug 1605197: MV2 cannot fall back to CORS. + { status: 0, response: "" } + : { status: 200, response: "http://example.com" }, + }); + + browser.test.sendMessage("done"); + } + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, // Needed for granted_host_permissions + manifest: { + manifest_version, + granted_host_permissions: true, // Test-only: grant permissions in MV3. + host_permissions: [ + "http://example.net/", + // Work-around for bug 1766752. + "http://example.com/", + // "http://example.org/" is intentionally missing. + ], + content_scripts: [ + { + matches: ["http://example.com/dummy"], + run_at: "document_end", + js: ["contentscript.js"], + }, + ], + }, + files: { + "contentscript.js": `(${contentScript})(${manifest_version})`, + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("done"); + await contentPage.close(); + + await extension.unload(); +} + +add_task(async function test_XHR_MV2() { + await test_xhr({ manifest_version: 2 }); +}); + +add_task(async function test_XHR_MV3() { + await test_xhr({ manifest_version: 3 }); +}); 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..bc00bc7fd5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js @@ -0,0 +1,169 @@ +"use strict"; + +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..a8e64a0eb0 --- /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, false); + 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..5ead942549 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js @@ -0,0 +1,444 @@ +"use strict"; + +const { AsyncShutdown } = ChromeUtils.importESModule( + "resource://gre/modules/AsyncShutdown.sys.mjs" +); +const { NativeManifests } = ChromeUtils.import( + "resource://gre/modules/NativeManifests.jsm" +); +const { FileUtils } = ChromeUtils.importESModule( + "resource://gre/modules/FileUtils.sys.mjs" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); +const { Subprocess } = ChromeUtils.importESModule( + "resource://gre/modules/Subprocess.sys.mjs" +); +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.importESModule( + "resource://testing-common/MockRegistry.sys.mjs" + ); + registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_win.sys.mjs", + }); +} else { + ChromeUtils.defineESModuleGetters(this, { + SubprocessImpl: "resource://gre/modules/subprocess/subprocess_unix.sys.mjs", + }); +} + +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); + + try { + PYTHON = await Subprocess.pathSearch(Services.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", + }, + manifestVersion: 2, + 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, manifestVersion: 2 }; + 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_failover.js b/toolkit/components/extensions/test/xpcshell/test_proxy_failover.js new file mode 100644 index 0000000000..e584f142fa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_failover.js @@ -0,0 +1,323 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +// Necessary for the pac script to proxy localhost requests +Services.prefs.setBoolPref("network.proxy.allow_hijacking_localhost", true); + +// Pref is not builtin if direct failover is disabled in compile config. +XPCOMUtils.defineLazyGetter(this, "directFailoverDisabled", () => { + return ( + Services.prefs.getPrefType("network.proxy.failover_direct") == + Ci.nsIPrefBranch.PREF_INVALID + ); +}); + +const { ServiceRequest } = ChromeUtils.importESModule( + "resource://gre/modules/ServiceRequest.sys.mjs" +); + +// Prevent the request from reaching out to the network. +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); + +// No hosts defined to avoid the default proxy filter setup. +const nonProxiedServer = createHttpServer(); +nonProxiedServer.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok!"); +}); +const { primaryHost, primaryPort } = nonProxiedServer.identity; + +function getProxyData(channel) { + if (!(channel instanceof Ci.nsIProxiedChannel) || !channel.proxyInfo) { + return; + } + let { type, host, port, sourceId } = channel.proxyInfo; + return { type, host, port, sourceId }; +} + +// Get a free port with no listener to use in the proxyinfo. +function getBadProxyPort() { + let server = new HttpServer(); + server.start(-1); + const badPort = server.identity.primaryPort; + server.stop(); + return badPort; +} + +function xhr(url, options = { beConservative: true, bypassProxy: false }) { + return new Promise((resolve, reject) => { + let req = new XMLHttpRequest(); + req.open("GET", `${url}?t=${Math.random()}`); + req.channel.QueryInterface(Ci.nsIHttpChannelInternal).beConservative = + options.beConservative; + req.channel.QueryInterface(Ci.nsIHttpChannelInternal).bypassProxy = + options.bypassProxy; + req.onload = () => { + resolve({ text: req.responseText, proxy: getProxyData(req.channel) }); + }; + req.onerror = () => { + reject({ status: req.status, proxy: getProxyData(req.channel) }); + }; + req.send(); + }); +} + +// Same as the above xhr call, but ServiceRequest is always beConservative. +// This is here to specifically test bypassProxy with ServiceRequest. +function serviceRequest(url, options = { bypassProxy: false }) { + return new Promise((resolve, reject) => { + let req = new ServiceRequest(); + req.open("GET", `${url}?t=${Math.random()}`, options); + req.onload = () => { + resolve({ text: req.responseText, proxy: getProxyData(req.channel) }); + }; + req.onerror = () => { + reject({ status: req.status, proxy: getProxyData(req.channel) }); + }; + req.send(); + }); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +async function getProxyExtension(proxyDetails) { + async function background(proxyDetails) { + browser.proxy.onRequest.addListener( + details => { + return proxyDetails; + }, + { urls: ["<all_urls>"] } + ); + + browser.test.sendMessage("proxied"); + } + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})(${JSON.stringify(proxyDetails)})`, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("proxied"); + return extension; +} + +add_task(async function test_failover_content_direct() { + // load a content page for fetch and non-system principal, expect + // failover to direct will work. + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + { type: "direct" }, + ]; + + // We need to load the content page before loading the proxy extension + // to ensure that we have a valid content page to run fetch from. + let contentUrl = `http://${primaryHost}:${primaryPort}/`; + let page = await ExtensionTestUtils.loadContentPage(contentUrl); + + let extension = await getProxyExtension(proxyDetails); + + await ExtensionTestUtils.fetch(contentUrl, `${contentUrl}?t=${Math.random()}`) + .then(text => { + equal(text, "ok!", "fetch completed"); + }) + .catch(() => { + ok(false, "fetch failed"); + }); + + await extension.unload(); + await page.close(); +}); + +add_task( + { skip_if: () => directFailoverDisabled }, + async function test_failover_content() { + // load a content page for fetch and non-system principal, expect + // no failover + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + // We need to load the content page before loading the proxy extension + // to ensure that we have a valid content page to run fetch from. + let contentUrl = `http://${primaryHost}:${primaryPort}/`; + let page = await ExtensionTestUtils.loadContentPage(contentUrl); + + let extension = await getProxyExtension(proxyDetails); + + await ExtensionTestUtils.fetch( + contentUrl, + `${contentUrl}?t=${Math.random()}` + ) + .then(text => { + ok(false, "xhr unexpectedly completed"); + }) + .catch(e => { + equal( + e.message, + "NetworkError when attempting to fetch resource.", + "fetch failed" + ); + }); + + await extension.unload(); + await page.close(); + } +); + +add_task( + { skip_if: () => directFailoverDisabled }, + async function test_failover_system() { + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + let extension = await getProxyExtension(proxyDetails); + + await xhr(`http://${primaryHost}:${primaryPort}/`) + .then(req => { + equal(req.proxy.type, "direct", "proxy failover to direct"); + equal(req.text, "ok!", "xhr completed"); + }) + .catch(req => { + ok(false, "xhr failed"); + }); + + await extension.unload(); + } +); + +add_task( + { + skip_if: () => + AppConstants.platform === "android" || directFailoverDisabled, + }, + async function test_failover_pac() { + const badPort = getBadProxyPort(); + + async function background(badPort) { + let pac = `function FindProxyForURL(url, host) { return "PROXY 127.0.0.1:${badPort}"; }`; + let proxySettings = { + proxyType: "autoConfig", + autoConfigUrl: `data:application/x-ns-proxy-autoconfig;charset=utf-8,${encodeURIComponent( + pac + )}`, + }; + + await browser.proxy.settings.set({ value: proxySettings }); + browser.test.sendMessage("proxied"); + } + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})(${badPort})`, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("proxied"); + equal( + Services.prefs.getIntPref("network.proxy.type"), + 2, + "autoconfig type set" + ); + ok( + Services.prefs.getStringPref("network.proxy.autoconfig_url"), + "autoconfig url set" + ); + + await xhr(`http://${primaryHost}:${primaryPort}/`) + .then(req => { + equal(req.proxy.type, "direct", "proxy failover to direct"); + equal(req.text, "ok!", "xhr completed"); + }) + .catch(() => { + ok(false, "xhr failed"); + }); + + await extension.unload(); + } +); + +add_task(async function test_bypass_proxy() { + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + let extension = await getProxyExtension(proxyDetails); + + await xhr(`http://${primaryHost}:${primaryPort}/`, { bypassProxy: true }) + .then(req => { + equal(req.proxy, undefined, "no proxy used"); + ok(true, "xhr completed"); + }) + .catch(req => { + equal(req.proxy, undefined, "no proxy used"); + ok(false, "xhr error"); + }); + + await extension.unload(); +}); + +add_task(async function test_bypass_proxy() { + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + let extension = await getProxyExtension(proxyDetails); + + await serviceRequest(`http://${primaryHost}:${primaryPort}/`, { + bypassProxy: true, + }) + .then(req => { + equal(req.proxy, undefined, "no proxy used"); + ok(true, "xhr completed"); + }) + .catch(req => { + equal(req.proxy, undefined, "no proxy used"); + ok(false, "xhr error"); + }); + + await extension.unload(); +}); + +add_task(async function test_failover_system_off() { + // Test test failover failures, uncomment and set pref to false + Services.prefs.setBoolPref("network.proxy.failover_direct", false); + + const proxyDetails = [ + { type: "http", host: "127.0.0.1", port: getBadProxyPort() }, + ]; + + let extension = await getProxyExtension(proxyDetails); + + await xhr(`http://${primaryHost}:${primaryPort}/`) + .then(req => { + equal(req.proxy.sourceId, extension.id, "extension matches"); + ok(false, "xhr completed"); + }) + .catch(req => { + equal(req.proxy.sourceId, extension.id, "extension matches"); + equal(req.status, 0, "xhr failed"); + }); + + await extension.unload(); +}); 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..a37996c221 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js @@ -0,0 +1,95 @@ +"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() { + // 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( + "https://example.com/dummy", + { privateBrowsing: true } + ); + await pextension.awaitMessage("proxy.onRequest.private"); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage( + "https://example.com/dummy" + ); + await extension.awaitFinish("proxy.onRequest"); + await pextension.awaitFinish("proxy.onRequest.spanning"); + await contentPage.close(); + + await pextension.unload(); + await extension.unload(); +}); 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..8cc46d45e7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js @@ -0,0 +1,298 @@ +"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_disabled() { + 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(); +}); + +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_site_permissions.js b/toolkit/components/extensions/test/xpcshell/test_site_permissions.js new file mode 100644 index 0000000000..d16bc9216d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_site_permissions.js @@ -0,0 +1,387 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// TODO(Bug 1789718): adapt to synthetic addon type implemented by the SitePermAddonProvider +// or remove if redundant, after the deprecated XPIProvider-based implementation is also removed. + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); + +const { TelemetryController } = ChromeUtils.importESModule( + "resource://gre/modules/TelemetryController.sys.mjs" +); +const { TelemetryTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TelemetryTestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const BROWSER_PROPERTIES = + AppConstants.MOZ_APP_NAME == "thunderbird" + ? "chrome://messenger/locale/addons.properties" + : "chrome://browser/locale/browser.properties"; + +// Lazily import ExtensionParent to allow AddonTestUtils.createAppInfo to +// override Services.appinfo. +ChromeUtils.defineModuleGetter( + this, + "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm" +); + +async function _test_manifest(manifest, expectedError) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let normalized = await ExtensionTestUtils.normalizeManifest( + manifest, + "manifest.WebExtensionSitePermissionsManifest" + ); + ExtensionTestUtils.failOnSchemaWarnings(true); + + if (expectedError) { + ok( + normalized.error.includes(expectedError), + `The manifest error ${JSON.stringify( + normalized.error + )} must contain ${JSON.stringify(expectedError)}` + ); + } else { + equal(normalized.error, undefined, "Should not have an error"); + } + equal(normalized.errors.length, 0, "Should have no warning"); +} + +add_setup(async () => { + // 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 + // release builds. + const oldCanRecordBase = Services.telemetry.canRecordBase; + Services.telemetry.canRecordBase = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordBase = oldCanRecordBase; + }); +}); + +add_task(async function test_manifest_site_permissions() { + await _test_manifest({ + site_permissions: ["midi"], + install_origins: ["http://example.com"], + }); + await _test_manifest({ + site_permissions: ["midi-sysex"], + install_origins: ["http://example.com"], + }); + await _test_manifest( + { + site_permissions: ["unknown_site_permission"], + install_origins: ["http://example.com"], + }, + `Error processing site_permissions.0: Invalid enumeration value "unknown_site_permission"` + ); + await _test_manifest( + { + site_permissions: ["unknown_site_permission"], + install_origins: [], + }, + `Error processing install_origins: Array requires at least 1 items;` + ); + await _test_manifest( + { + site_permissions: ["unknown_site_permission"], + }, + `Property "install_origins" is required` + ); + await _test_manifest( + { + install_origins: ["http://example.com"], + }, + `Property "site_permissions" is required` + ); + // test any extra manifest entries not part of a site permissions addon will cause an error. + await _test_manifest( + { + site_permissions: ["midi"], + install_origins: ["http://example.com"], + permissions: ["webRequest"], + }, + `Unexpected property` + ); +}); + +add_task(async function test_sitepermission_telemetry() { + await AddonTestUtils.promiseStartupManager(); + + Services.telemetry.clearEvents(); + + const addon_id = "webmidi@test"; + const origin = "https://example.com"; + const permName = "midi"; + + let site_permission = { + "manifest.json": { + name: "test Site Permission", + version: "1.0", + manifest_version: 2, + browser_specific_settings: { + gecko: { id: addon_id }, + }, + install_origins: [origin], + site_permissions: [permName], + }, + }; + + let [, { addon }] = await Promise.all([ + TestUtils.topicObserved("webextension-sitepermissions-startup"), + AddonTestUtils.promiseInstallXPI(site_permission), + ]); + + await addon.uninstall(); + + await TelemetryTestUtils.assertEvents( + [ + [ + "addonsManager", + "install", + "siteperm_deprecated", + /.*/, + { + step: "started", + addon_id, + }, + ], + [ + "addonsManager", + "install", + "siteperm_deprecated", + /.*/, + { + step: "completed", + addon_id, + }, + ], + ["addonsManager", "uninstall", "siteperm_deprecated", addon_id], + ], + { + category: "addonsManager", + method: /^install|uninstall$/, + } + ); + + await AddonTestUtils.promiseShutdownManager(); +}); + +async function _test_ext_site_permissions(site_permissions, install_origins) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + install_origins, + site_permissions, + }, + }); + await extension.startup(); + await extension.unload(); + ExtensionTestUtils.failOnSchemaWarnings(true); +} + +add_task(async function test_ext_site_permissions() { + await _test_ext_site_permissions(["midi"], ["http://example.com"]); + + await _test_ext_site_permissions( + ["midi"], + ["http://example.com", "http://foo.com"] + ).catch(e => { + Assert.ok( + e.message.includes( + "Error processing install_origins: Array requires at most 1 items; you have 2" + ), + "Site permissions can only contain one install origin: " + ); + }); +}); + +add_task(async function test_sitepermission_type() { + await AddonTestUtils.promiseStartupManager(); + + // Test more than one perm to make sure both are added. + // While this is allowed, midi-sysex overrides. + let perms = ["midi", "midi-sysex"]; + let id = "@test-permission"; + let origin = "http://example.com"; + let uri = Services.io.newURI(origin); + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + + // give the site some other permission (geo) + Services.perms.addFromPrincipal( + principal, + "geo", + Services.perms.ALLOW_ACTION, + Services.perms.EXPIRE_NEVER + ); + + let assertGeo = () => { + Assert.equal( + Services.perms.testExactPermissionFromPrincipal(principal, "geo"), + Ci.nsIPermissionManager.ALLOW_ACTION, + "site still has geo permission" + ); + }; + + let checkPerms = (perms, action, msg) => { + for (let permName of perms) { + let permission = Services.perms.testExactPermissionFromPrincipal( + principal, + permName + ); + Assert.equal(permission, action, `${permName}: ${msg}`); + } + }; + + checkPerms( + perms, + Ci.nsIPermissionManager.UNKNOWN_ACTION, + "no permission for site" + ); + + let site_permission = { + "manifest.json": { + name: "test Site Permission", + version: "1.0", + manifest_version: 2, + browser_specific_settings: { + gecko: { + id, + }, + }, + install_origins: [origin], + site_permissions: perms, + }, + }; + + let [, { addon }] = await Promise.all([ + TestUtils.topicObserved("webextension-sitepermissions-startup"), + AddonTestUtils.promiseInstallXPI(site_permission), + ]); + + checkPerms( + perms, + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test the permission is retained on restart. + await AddonTestUtils.promiseRestartManager(); + addon = await AddonManager.getAddonByID(id); + + checkPerms( + perms, + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test that a removed permission is added on restart + Services.perms.removeFromPrincipal(principal, perms[0]); + await AddonTestUtils.promiseRestartManager(); + addon = await AddonManager.getAddonByID(id); + + checkPerms( + perms, + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test that a changed permission is not changed on restart + Services.perms.addFromPrincipal( + principal, + perms[0], + Services.perms.DENY_ACTION, + Services.perms.EXPIRE_NEVER + ); + + await AddonTestUtils.promiseRestartManager(); + addon = await AddonManager.getAddonByID(id); + + checkPerms( + [perms[0]], + Ci.nsIPermissionManager.DENY_ACTION, + "extension enabled permission for site" + ); + checkPerms( + [perms[1]], + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test permission removal when addon disabled + await addon.disable(); + + checkPerms( + perms, + Ci.nsIPermissionManager.UNKNOWN_ACTION, + "no permission for site" + ); + assertGeo(); + + // Enabling an addon will always force ALLOW_ACTION + await addon.enable(); + + checkPerms( + perms, + Ci.nsIPermissionManager.ALLOW_ACTION, + "extension enabled permission for site" + ); + assertGeo(); + + // Test permission removal when addon uninstalled + await addon.uninstall(); + + checkPerms( + perms, + Ci.nsIPermissionManager.UNKNOWN_ACTION, + "no permission for site" + ); + assertGeo(); +}); + +add_task(async function test_site_permissions_have_localization_strings() { + await ExtensionParent.apiManager.lazyInit(); + const SCHEMA_SITE_PERMISSIONS = Schemas.getPermissionNames([ + "SitePermission", + ]); + ok(SCHEMA_SITE_PERMISSIONS.length, "we have site permissions"); + + const bundle = Services.strings.createBundle(BROWSER_PROPERTIES); + + for (const perm of SCHEMA_SITE_PERMISSIONS) { + try { + const str = bundle.GetStringFromName( + `webextSitePerms.description.${perm}` + ); + + ok(str.length, `Found localization string for '${perm}' site permission`); + } catch (e) { + ok(false, `Site permission missing '${perm}'`); + } + } +}); 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..2dda1e5e68 --- /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.importESModule( + "resource://gre/modules/PromiseUtils.sys.mjs" +); +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/webidl-api/.eslintrc.js b/toolkit/components/extensions/test/xpcshell/webidl-api/.eslintrc.js new file mode 100644 index 0000000000..3622fff4f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/.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/webidl-api/head_webidl_api.js b/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js new file mode 100644 index 0000000000..600615dbfa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/head_webidl_api.js @@ -0,0 +1,313 @@ +/* import-globals-from ../head.js */ + +/* exported getBackgroundServiceWorkerRegistration, waitForTerminatedWorkers, + * runExtensionAPITest */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +XPCOMUtils.defineLazyModuleGetters(this, { + ExtensionTestCommon: "resource://testing-common/ExtensionTestCommon.jsm", +}); + +add_setup(function checkExtensionsWebIDLEnabled() { + equal( + AppConstants.MOZ_WEBEXT_WEBIDL_ENABLED, + true, + "WebExtensions WebIDL bindings build time flag should be enabled" + ); +}); + +function getBackgroundServiceWorkerRegistration(extension) { + const swm = Cc["@mozilla.org/serviceworkers/manager;1"].getService( + Ci.nsIServiceWorkerManager + ); + + const swRegs = swm.getAllRegistrations(); + const scope = `moz-extension://${extension.uuid}/`; + + for (let i = 0; i < swRegs.length; i++) { + let regInfo = swRegs.queryElementAt(i, Ci.nsIServiceWorkerRegistrationInfo); + if (regInfo.scope === scope) { + return regInfo; + } + } +} + +function waitForTerminatedWorkers(swRegInfo) { + info(`Wait all ${swRegInfo.scope} workers to be terminated`); + return TestUtils.waitForCondition(() => { + const { + evaluatingWorker, + installingWorker, + waitingWorker, + activeWorker, + } = swRegInfo; + return !( + evaluatingWorker || + installingWorker || + waitingWorker || + activeWorker + ); + }, `wait workers for scope ${swRegInfo.scope} to be terminated`); +} + +function unmockHandleAPIRequest(extPage) { + return extPage.spawn([], () => { + const { ExtensionAPIRequestHandler } = ChromeUtils.import( + "resource://gre/modules/ExtensionProcessScript.jsm" + ); + + // Unmock ExtensionAPIRequestHandler. + if (ExtensionAPIRequestHandler._handleAPIRequest_orig) { + ExtensionAPIRequestHandler.handleAPIRequest = + ExtensionAPIRequestHandler._handleAPIRequest_orig; + delete ExtensionAPIRequestHandler._handleAPIRequest_orig; + } + }); +} + +function mockHandleAPIRequest(extPage, mockHandleAPIRequest) { + mockHandleAPIRequest = + mockHandleAPIRequest || + ((policy, request) => { + const ExtError = request.window?.Error || Error; + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new ExtError( + "mockHandleAPIRequest not defined by this test case" + ), + }; + }); + + return extPage.spawn( + [ExtensionTestCommon.serializeFunction(mockHandleAPIRequest)], + mockFnText => { + const { ExtensionAPIRequestHandler } = ChromeUtils.import( + "resource://gre/modules/ExtensionProcessScript.jsm" + ); + + mockFnText = `(() => { + return (${mockFnText}); + })();`; + // eslint-disable-next-line no-eval + const mockFn = eval(mockFnText); + + // Mock ExtensionAPIRequestHandler. + if (!ExtensionAPIRequestHandler._handleAPIRequest_orig) { + ExtensionAPIRequestHandler._handleAPIRequest_orig = + ExtensionAPIRequestHandler.handleAPIRequest; + } + + ExtensionAPIRequestHandler.handleAPIRequest = function(policy, request) { + if (request.apiNamespace === "test") { + return this._handleAPIRequest_orig(policy, request); + } + return mockFn.call(this, policy, request); + }; + } + ); +} + +/** + * An helper function used to run unit test that are meant to test the + * Extension API webidl bindings helpers shared by all the webextensions + * API namespaces. + * + * @param {string} testDescription + * Brief description of the test. + * @param {object} [options] + * @param {Function} options.backgroundScript + * Test function running in the extension global. This function + * does receive a parameter of type object with the following + * properties: + * - testLog(message): log a message on the terminal + * - testAsserts: + * - isErrorInstance(err): throw if err is not an Error instance + * - isInstanceOf(value, globalContructorName): throws if value + * is not an instance of global[globalConstructorName] + * - equal(val, exp, msg): throw an error including msg if + * val is not strictly equal to exp. + * @param {Function} options.assertResults + * Function to be provided to assert the result returned by + * `backgroundScript`, or assert the error if it did throw. + * This function does receive a parameter of type object with + * the following properties: + * - testResult: the result returned (and resolved if the return + * value was a promise) from the call to `backgroundScript` + * - testError: the error raised (or rejected if the return value + * value was a promise) from the call to `backgroundScript` + * - extension: the extension wrapper created by this helper. + * @param {Function} options.mockAPIRequestHandler + * Function to be used to mock mozIExtensionAPIRequestHandler.handleAPIRequest + * for the purpose of the test. + * This function received the same parameter that are listed in the idl + * definition (mozIExtensionAPIRequestHandling.webidl). + * @param {string} [options.extensionId] + * Optional extension id for the test extension. + */ +async function runExtensionAPITest( + testDescription, + { + backgroundScript, + assertResults, + mockAPIRequestHandler, + extensionId = "test-ext-api-request-forward@mochitest", + } +) { + // Wraps the `backgroundScript` function to be execute in the target + // extension global (currently only in a background service worker, + // in follow-ups the same function should also be execute in + // other supported extension globals, e.g. an extension page and + // a content script). + // + // The test wrapper does also provide to `backgroundScript` some + // helpers to be used as part of the test, these tests are meant to + // only cover internals shared by all webidl API bindings through a + // mock API namespace only available in tests (and so none of the tests + // written with this helpers should be using the browser.test API namespace). + function backgroundScriptWrapper(testParams, testFn) { + const testLog = msg => { + // console messages emitted by workers are not visible in the test logs if not + // explicitly collected, and so this testLog helper method does use dump for now + // (this way the logs will be visibile as part of the test logs). + dump(`"${testParams.extensionId}": ${msg}\n`); + }; + + const testAsserts = { + isErrorInstance(err) { + if (!(err instanceof Error)) { + throw new Error("Unexpected error: not an instance of Error"); + } + return true; + }, + isInstanceOf(value, globalConstructorName) { + if (!(value instanceof self[globalConstructorName])) { + throw new Error( + `Unexpected error: expected instance of ${globalConstructorName}` + ); + } + return true; + }, + equal(val, exp, msg) { + if (val !== exp) { + throw new Error( + `Unexpected error: expected ${exp} but got ${val}. ${msg}` + ); + } + }, + }; + + testLog(`Evaluating - test case "${testParams.testDescription}"`); + self.onmessage = async evt => { + testLog(`Running test case "${testParams.testDescription}"`); + + let testError = null; + let testResult; + try { + testResult = await testFn({ testLog, testAsserts }); + } catch (err) { + testError = { message: err.message, stack: err.stack }; + testLog(`Unexpected test error: ${err} :: ${err.stack}\n`); + } + + evt.ports[0].postMessage({ success: !testError, testError, testResult }); + + testLog(`Test case "${testParams.testDescription}" executed`); + }; + testLog(`Wait onmessage event - test case "${testParams.testDescription}"`); + } + + async function assertTestResult(result) { + if (assertResults) { + await assertResults(result); + } else { + equal(result.testError, undefined, "Expect no errors"); + ok(result.success, "Test completed successfully"); + } + } + + async function runTestCaseInWorker({ page, extension }) { + info(`*** Run test case in an extension service worker`); + const result = await page.spawn([], async () => { + const { active } = await content.navigator.serviceWorker.ready; + const { port1, port2 } = new MessageChannel(); + + return new Promise(resolve => { + port1.onmessage = evt => resolve(evt.data); + active.postMessage("run-test", [port2]); + }); + }); + info(`*** Assert test case results got from extension service worker`); + await assertTestResult({ ...result, extension }); + } + + // NOTE: prefixing this with `function ` is needed because backgroundScript + // is an object property and so it is going to be stringified as + // `backgroundScript() { ... }` (which would be detected as a syntax error + // on the worker script evaluation phase). + const scriptFnParam = ExtensionTestCommon.serializeFunction(backgroundScript); + const testOptsParam = `${JSON.stringify({ testDescription, extensionId })}`; + + const testExtData = { + useAddonManager: "temporary", + manifest: { + version: "1", + background: { + service_worker: "test-sw.js", + }, + browser_specific_settings: { + gecko: { id: extensionId }, + }, + }, + files: { + "page.html": `<!DOCTYPE html> + <head><meta charset="utf-8"></head> + <body> + <script src="test-sw.js"></script> + </body>`, + "test-sw.js": ` + (${backgroundScriptWrapper})(${testOptsParam}, ${scriptFnParam}); + `, + }, + }; + + let cleanupCalled = false; + let extension; + let page; + let swReg; + + async function testCleanup() { + if (cleanupCalled) { + return; + } + + cleanupCalled = true; + await unmockHandleAPIRequest(page); + await page.close(); + await extension.unload(); + await waitForTerminatedWorkers(swReg); + } + + info(`Start test case "${testDescription}"`); + extension = ExtensionTestUtils.loadExtension(testExtData); + await extension.startup(); + + swReg = getBackgroundServiceWorkerRegistration(extension); + ok(swReg, "Extension background.service_worker should be registered"); + + page = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html`, + { extension } + ); + + registerCleanupFunction(testCleanup); + + await mockHandleAPIRequest(page, mockAPIRequestHandler); + await runTestCaseInWorker({ page, extension }); + await testCleanup(); + info(`End test case "${testDescription}"`); +} diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js new file mode 100644 index 0000000000..489cc3a754 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api.js @@ -0,0 +1,486 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_ext_context_does_have_webidl_bindings() { + await runExtensionAPITest("should have a browser global object", { + backgroundScript() { + const { browser, chrome } = self; + + return { + hasExtensionAPI: !!browser, + hasExtensionMockAPI: !!browser?.mockExtensionAPI, + hasChromeCompatGlobal: !!chrome, + hasChromeMockAPI: !!chrome?.mockExtensionAPI, + }; + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, undefined); + Assert.deepEqual( + testResult, + { + hasExtensionAPI: true, + hasExtensionMockAPI: true, + hasChromeCompatGlobal: true, + hasChromeMockAPI: true, + }, + "browser and browser.test WebIDL API bindings found" + ); + }, + }); +}); + +add_task(async function test_propagated_extension_error() { + await runExtensionAPITest( + "should throw an extension error on ResultType::EXTENSION_ERROR", + { + backgroundScript({ testAsserts }) { + try { + const api = self.browser.mockExtensionAPI; + api.methodSyncWithReturn("arg0", 1, { value: "arg2" }); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Extension Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Extension Error"); + }, + } + ); +}); + +add_task(async function test_system_errors_donot_leak() { + function assertResults({ testError }) { + ok( + testError?.message?.match(/An unexpected error occurred/), + `Got the general unexpected error as expected: ${testError?.message}` + ); + } + + function mockAPIRequestHandler(policy, request) { + throw new Error("Fake handleAPIRequest exception"); + } + + const msg = + "should throw an unexpected error occurred if handleAPIRequest throws"; + + await runExtensionAPITest(`sync method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodSyncWithReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); + + await runExtensionAPITest(`async method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); + + await runExtensionAPITest(`no return method ${msg}`, { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler, + assertResults, + }); +}); + +add_task(async function test_call_sync_function_result() { + await runExtensionAPITest( + "sync API methods should support structured clonable return values", + { + backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const results = { + string: api.methodSyncWithReturn("string-result"), + nested_prop: api.methodSyncWithReturn({ + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }), + }; + + testAsserts.isInstanceOf(results.nested_prop.date, "Date"); + testAsserts.isInstanceOf(results.nested_prop.map, "Map"); + return results; + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName === "methodSyncWithReturn") { + // Return the first argument unmodified, which will be checked in the + // resultAssertFn above. + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: request.args[0], + }; + } + throw new Error("Unexpected API method"); + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual(testResult, { + string: "string-result", + nested_prop: { + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }, + }); + }, + } + ); +}); + +add_task(async function test_call_sync_fn_missing_return() { + await runExtensionAPITest( + "should throw an unexpected error occurred on missing return value", + { + backgroundScript() { + self.browser.mockExtensionAPI.methodSyncWithReturn("arg0"); + }, + mockAPIRequestHandler(policy, request) { + return undefined; + }, + assertResults({ testError }) { + ok( + testError?.message?.match(/An unexpected error occurred/), + `Got the general unexpected error as expected: ${testError?.message}` + ); + }, + } + ); +}); + +add_task(async function test_call_async_throw_extension_error() { + await runExtensionAPITest( + "an async function can throw an error occurred for param validation errors", + { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Param Validation Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Param Validation Error"); + }, + } + ); +}); + +add_task(async function test_call_async_reject_error() { + await runExtensionAPITest( + "an async function rejected promise should propagate extension errors", + { + async backgroundScript({ testAsserts }) { + try { + await self.browser.mockExtensionAPI.methodAsync("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.reject(new Error("Fake API rejected error object")), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake API rejected error object"); + }, + } + ); +}); + +add_task(async function test_call_async_function_result() { + await runExtensionAPITest( + "async API methods should support structured clonable resolved values", + { + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const results = { + string: await api.methodAsync("string-result"), + nested_prop: await api.methodAsync({ + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }), + }; + + testAsserts.isInstanceOf(results.nested_prop.date, "Date"); + testAsserts.isInstanceOf(results.nested_prop.map, "Map"); + return results; + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName === "methodAsync") { + // Return the first argument unmodified, which will be checked in the + // resultAssertFn above. + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.resolve(request.args[0]), + }; + } + throw new Error("Unexpected API method"); + }, + assertResults({ testResult, testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual(testResult, { + string: "string-result", + nested_prop: { + string: "123", + number: 123, + date: new Date("2020-09-20"), + map: new Map([ + ["a", 1], + ["b", 2], + ]), + }, + }); + }, + } + ); +}); + +add_task(async function test_call_no_return_throw_extension_error() { + await runExtensionAPITest( + "no return function call throw an error occurred for param validation errors", + { + backgroundScript({ testAsserts }) { + try { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + } catch (err) { + testAsserts.isErrorInstance(err); + throw err; + } + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.EXTENSION_ERROR, + value: new Error("Fake Param Validation Error"), + }; + }, + assertResults({ testError }) { + Assert.deepEqual(testError?.message, "Fake Param Validation Error"); + }, + } + ); +}); + +add_task(async function test_call_no_return_without_errors() { + await runExtensionAPITest( + "handleAPIHandler can return undefined on api calls to methods with no return", + { + backgroundScript() { + self.browser.mockExtensionAPI.methodNoReturn("arg0"); + }, + mockAPIRequestHandler(policy, request) { + return undefined; + }, + assertResults({ testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + } + ); +}); + +add_task(async function test_async_method_chrome_compatible_callback() { + function mockAPIRequestHandler(policy, request) { + if (request.args[0] === "fake-async-method-failure") { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.reject("this-should-not-be-passed-to-cb-as-parameter"), + }; + } + + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: Promise.resolve(request.args), + }; + } + + await runExtensionAPITest( + "async method should support an optional chrome-compatible callback", + { + mockAPIRequestHandler, + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const success_cb_params = await new Promise(resolve => { + const res = api.methodAsync( + { prop: "fake-async-method-success" }, + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + const error_cb_params = await new Promise(resolve => { + const res = api.methodAsync( + "fake-async-method-failure", + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + return { success_cb_params, error_cb_params }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + success_cb_params: [[{ prop: "fake-async-method-success" }]], + error_cb_params: [], + }, + "Got the expected results from the chrome compatible callbacks" + ); + }, + } + ); + + await runExtensionAPITest( + "async method with ambiguous args called with a chrome-compatible callback", + { + mockAPIRequestHandler, + async backgroundScript({ testAsserts }) { + const api = self.browser.mockExtensionAPI; + const success_cb_params = await new Promise(resolve => { + const res = api.methodAmbiguousArgsAsync( + "arg0", + { prop: "arg1" }, + 3, + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + const error_cb_params = await new Promise(resolve => { + const res = api.methodAmbiguousArgsAsync( + "fake-async-method-failure", + (...results) => { + resolve(results); + } + ); + testAsserts.equal(res, undefined, "no promise should be returned"); + }); + return { success_cb_params, error_cb_params }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + success_cb_params: [["arg0", { prop: "arg1" }, 3]], + error_cb_params: [], + }, + "Got the expected results from the chrome compatible callbacks" + ); + }, + } + ); +}); + +add_task(async function test_get_property() { + await runExtensionAPITest( + "getProperty API request does return a value synchrously", + { + backgroundScript() { + return self.browser.mockExtensionAPI.propertyAsString; + }, + mockAPIRequestHandler(policy, request) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: "property-value", + }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + "property-value", + "Got the expected result" + ); + }, + } + ); + + await runExtensionAPITest( + "getProperty API request can return an error object", + { + backgroundScript({ testAsserts }) { + const errObj = self.browser.mockExtensionAPI.propertyAsErrorObject; + testAsserts.isErrorInstance(errObj); + testAsserts.equal(errObj.message, "fake extension error"); + }, + mockAPIRequestHandler(policy, request) { + let savedFrame = request.calledSavedFrame; + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: ChromeUtils.createError("fake extension error", savedFrame), + }; + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js new file mode 100644 index 0000000000..576ec760d3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_event_callback.js @@ -0,0 +1,575 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from ../head_service_worker.js */ + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_api_event_manager_methods() { + await runExtensionAPITest("extension event manager methods", { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + const listener = () => {}; + + function assertHasListener(expect) { + testAsserts.equal( + api.onTestEvent.hasListeners(), + expect, + `onTestEvent.hasListeners should return {expect}` + ); + testAsserts.equal( + api.onTestEvent.hasListener(listener), + expect, + `onTestEvent.hasListeners should return {expect}` + ); + } + + assertHasListener(false); + api.onTestEvent.addListener(listener); + assertHasListener(true); + api.onTestEvent.removeListener(listener); + assertHasListener(false); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + }); +}); + +add_task(async function test_api_event_eventListener_call() { + await runExtensionAPITest( + "extension event eventListener wrapper does forward calls parameters", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (...args) => { + testLog("onTestEvent"); + // Make sure the extension code can access the arguments. + try { + testAsserts.equal(args[1], "arg1"); + resolve(args); + } catch (err) { + reject(err); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + if (request.requestType === "addListener") { + let args = [{ arg: 0 }, "arg1"]; + request.eventListener.callListener(args); + } + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + [{ arg: 0 }, "arg1"], + "Got the expected result" + ); + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_call_with_result() { + await runExtensionAPITest( + "extension event eventListener wrapper forwarded call result", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (msg, value) => { + testLog(`onTestEvent received: ${msg}`); + switch (msg) { + case "test-result-value": + return value; + case "test-promise-resolve": + return Promise.resolve(value); + case "test-promise-reject": + return Promise.reject(new Error("test-reject")); + case "test-done": + resolve(value); + break; + default: + reject(new Error(`Unexpected onTestEvent message: ${msg}`)); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult?.resSync, + { prop: "retval" }, + "Got result from eventListener returning a plain return value" + ); + Assert.deepEqual( + testResult?.resAsync, + { prop: "promise" }, + "Got result from eventListener returning a resolved promise" + ); + Assert.deepEqual( + testResult?.resAsyncReject, + { + isInstanceOfError: true, + errorMessage: "test-reject", + }, + "got result from eventListener returning a rejected promise" + ); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + try { + dump(`calling listener, expect a plain return value\n`); + const resSync = await request.eventListener.callListener([ + "test-result-value", + { prop: "retval" }, + ]); + + dump( + `calling listener, expect a resolved promise return value\n` + ); + const resAsync = await request.eventListener.callListener([ + "test-promise-resolve", + { prop: "promise" }, + ]); + + dump( + `calling listener, expect a rejected promise return value\n` + ); + const resAsyncReject = await request.eventListener + .callListener(["test-promise-reject"]) + .catch(err => err); + + // call API listeners once more to complete the test + let args = { + resSync, + resAsync, + resAsyncReject: { + isInstanceOfError: resAsyncReject instanceof Error, + errorMessage: resAsyncReject?.message, + }, + }; + request.eventListener.callListener(["test-done", args]); + } catch (err) { + dump(`Unexpected error: ${err} :: ${err.stack}\n`); + throw err; + } + }); + } + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_result_rejected() { + await runExtensionAPITest( + "extension event eventListener throws (mozIExtensionCallback.call)", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = (msg, arg1) => { + if (msg === "test-done") { + testLog(`Resolving result: ${JSON.stringify(arg1)}`); + resolve(arg1); + return; + } + throw new Error("FAKE eventListener exception"); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + { + isPromise: true, + rejectIsError: true, + errorMessage: "FAKE eventListener exception", + }, + "Got the expected rejected promise" + ); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + const promiseResult = request.eventListener.callListener([]); + const isPromise = promiseResult instanceof Promise; + const err = await promiseResult.catch(e => e); + const rejectIsError = err instanceof Error; + request.eventListener.callListener([ + "test-done", + { isPromise, rejectIsError, errorMessage: err?.message }, + ]); + }); + } + }, + } + ); +}); + +add_task(async function test_api_event_eventListener_throws_on_call() { + await runExtensionAPITest( + "extension event eventListener throws (mozIExtensionCallback.call)", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listener = (msg, arg1) => { + if (msg === "test-done") { + testLog(`Resolving result: ${JSON.stringify(arg1)}`); + resolve(); + return; + } + throw new Error("FAKE eventListener exception"); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + request.eventListener.callListener([]); + request.eventListener.callListener(["test-done"]); + }); + } + }, + } + ); +}); + +add_task(async function test_send_response_eventListener() { + await runExtensionAPITest( + "extension event eventListener sendResponse eventListener argument", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listener = (msg, sendResponse) => { + if (msg === "call-sendResponse") { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => sendResponse("sendResponse-value"), 20); + return true; + } + + resolve(msg); + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.equal(testResult, "sendResponse-value", "Got expected value"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + Promise.resolve().then(async () => { + const res = await request.eventListener.callListener( + ["call-sendResponse"], + { + callbackType: + Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE, + } + ); + request.eventListener.callListener([res]); + }); + } + }, + } + ); +}); + +add_task(async function test_send_response_multiple_eventListener() { + await runExtensionAPITest("multiple extension event eventListeners", { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listenerNoReply; + let listenerSendResponseReply; + + return new Promise(resolve => { + testLog("addListener and wait for event to be fired"); + listenerNoReply = (msg, sendResponse) => { + return false; + }; + listenerSendResponseReply = (msg, sendResponse) => { + if (msg === "call-sendResponse") { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => sendResponse("sendResponse-value"), 20); + return true; + } + + resolve(msg); + }; + api.onTestEvent.addListener(listenerNoReply); + api.onTestEvent.addListener(listenerSendResponseReply); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.equal(testResult, "sendResponse-value", "Got expected value"); + }, + mockAPIRequestHandler(policy, request) { + if (!request.eventListener) { + throw new Error( + "Unexpected Error: missing ExtensionAPIRequest.eventListener" + ); + } + + if (request.requestType === "addListener") { + this._listeners = this._listeners || []; + this._listeners.push(request.eventListener); + if (this._listeners.length === 2) { + Promise.resolve().then(async () => { + const { _listeners } = this; + this._listeners = undefined; + + // Reference to the listener to which we should send the + // final message to complete the test. + const replyListener = _listeners[1]; + + const res = await Promise.race( + _listeners.map(l => + l.callListener(["call-sendResponse"], { + callbackType: + Ci.mozIExtensionListenerCallOptions.CALLBACK_SEND_RESPONSE, + }) + ) + ); + replyListener.callListener([res]); + }); + } + } + }, + }); +}); + +// Unit test nsIServiceWorkerManager.wakeForExtensionAPIEvent method. +add_task(async function test_serviceworkermanager_wake_for_api_event_helper() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { + gecko: { id: "test-bg-sw-wakeup@mochi.test" }, + }, + }, + files: { + "sw.js": ` + dump("Background ServiceWorker - executing\\n"); + const lifecycleEvents = []; + self.oninstall = () => { + dump('Background ServiceWorker - oninstall\\n'); + lifecycleEvents.push("install"); + }; + self.onactivate = () => { + dump('Background ServiceWorker - onactivate\\n'); + lifecycleEvents.push("activate"); + }; + browser.test.onMessage.addListener(msg => { + if (msg === "bgsw-getSWEvents") { + browser.test.sendMessage("bgsw-gotSWEvents", lifecycleEvents); + return; + } + + browser.test.fail("Got unexpected test message: " + msg); + }); + + const fakeListener01 = () => {}; + const fakeListener02 = () => {}; + + // Adding and removing the same listener, and so we expect + // ExtensionEventWakeupMap to not have any wakeup listener + // for the runtime.onInstalled event. + browser.runtime.onInstalled.addListener(fakeListener01); + browser.runtime.onInstalled.removeListener(fakeListener01); + // Removing the same listener more than ones should make any + // difference, and it shouldn't trigger any assertion in + // debug builds. + browser.runtime.onInstalled.removeListener(fakeListener01); + + browser.runtime.onStartup.addListener(fakeListener02); + // Removing an unrelated listener, runtime.onStartup is expected to + // still have one wakeup listener tracked by ExtensionEventWakeupMap. + browser.runtime.onStartup.removeListener(fakeListener01); + + browser.test.sendMessage("bgsw-executed"); + dump("Background ServiceWorker - executed\\n"); + `, + }, + }); + + const testWorkerWatcher = new TestWorkerWatcher("../data"); + let watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + await extension.startup(); + + info("Wait for the background service worker to be spawned"); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + + await extension.awaitMessage("bgsw-executed"); + + extension.sendMessage("bgsw-getSWEvents"); + let lifecycleEvents = await extension.awaitMessage("bgsw-gotSWEvents"); + Assert.deepEqual( + lifecycleEvents, + ["install", "activate"], + "Got install and activate lifecycle events as expected" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + const swReg = testWorkerWatcher.getRegistration(extension); + ok(swReg, "Got a service worker registration"); + ok(swReg?.activeWorker, "Got an active worker"); + + watcher = await testWorkerWatcher.watchExtensionServiceWorker(extension); + + const extensionBaseURL = extension.extension.baseURI.spec; + + async function testWakeupOnAPIEvent(eventName, expectedResult) { + const result = await testWorkerWatcher.swm.wakeForExtensionAPIEvent( + extensionBaseURL, + "runtime", + eventName + ); + equal( + result, + expectedResult, + `Got expected result from wakeForExtensionAPIEvent for ${eventName}` + ); + info( + `Wait for the background service worker to be spawned for ${eventName}` + ); + ok( + await watcher.promiseWorkerSpawned, + "The extension service worker has been spawned as expected" + ); + await extension.awaitMessage("bgsw-executed"); + } + + info("Wake up active worker for API event"); + // Extension API event listener has been added and removed synchronously by + // the worker script, and so we expect the promise to resolve successfully + // to `false`. + await testWakeupOnAPIEvent("onInstalled", false); + + extension.sendMessage("bgsw-getSWEvents"); + lifecycleEvents = await extension.awaitMessage("bgsw-gotSWEvents"); + Assert.deepEqual( + lifecycleEvents, + [], + "No install and activate lifecycle events expected on spawning active worker" + ); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + info("Wakeup again with an API event that has been subscribed"); + // Extension API event listener has been added synchronously (and not removed) + // by the worker script, and so we expect the promise to resolve successfully + // to `true`. + await testWakeupOnAPIEvent("onStartup", true); + + info("Wait for the background service worker to be terminated"); + ok( + await watcher.terminate(), + "The extension service worker has been terminated as expected" + ); + + await extension.unload(); + + await Assert.rejects( + testWorkerWatcher.swm.wakeForExtensionAPIEvent( + extensionBaseURL, + "runtime", + "onStartup" + ), + /Not an extension principal or extension disabled/, + "Got the expected rejection on wakeForExtensionAPIEvent called for an uninstalled extension" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js new file mode 100644 index 0000000000..588dabd937 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_request_handler.js @@ -0,0 +1,443 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests for +// an ext-*.js API module running in the local process +// (toolkit/components/extensions/child/ext-test.js). +add_task(async function test_sw_api_request_handling_local_process_api() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function() { + browser.test.onMessage.addListener(async msg => { + browser.test.succeed("call to test.succeed"); + browser.test.assertTrue(true, "call to test.assertTrue"); + browser.test.assertFalse(false, "call to test.assertFalse"); + // Smoke test assertEq (more complete coverage of the behavior expected + // by the test API will be introduced in test_ext_test.html as part of + // Bug 1723785). + const errorObject = new Error("fake_error_message"); + browser.test.assertEq( + errorObject, + errorObject, + "call to test.assertEq" + ); + + // Smoke test for assertThrows/assertRejects. + const errorMatchingTestCases = [ + ["expected error instance", errorObject], + ["expected error message string", "fake_error_message"], + ["expected regexp", /fake_error/], + ["matching function", error => errorObject === error], + ["matching Constructor", Error], + ]; + + browser.test.log("run assertThrows smoke tests"); + + const throwFn = () => { + throw errorObject; + }; + for (const [msg, expected] of errorMatchingTestCases) { + browser.test.assertThrows( + throwFn, + expected, + `call to assertThrow with ${msg}` + ); + } + + browser.test.log("run assertRejects smoke tests"); + + const rejectedPromise = Promise.reject(errorObject); + for (const [msg, expected] of errorMatchingTestCases) { + await browser.test.assertRejects( + rejectedPromise, + expected, + `call to assertRejects with ${msg}` + ); + } + + browser.test.notifyPass("test-completed"); + }); + browser.test.sendMessage("bgsw-ready"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bgsw-ready"); + extension.sendMessage("test-message-ok"); + await extension.awaitFinish(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests for +// an ext-*.js API module running in the main process +// (toolkit/components/extensions/parent/ext-alarms.js). +add_task(async function test_sw_api_request_handling_main_process_api() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: ["alarms"], + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function() { + browser.alarms.create("test-alarm", { when: Date.now() + 2000000 }); + const all = await browser.alarms.getAll(); + if (all.length === 1 && all[0].name === "test-alarm") { + browser.test.succeed("Got the expected alarms"); + } else { + browser.test.fail( + `browser.alarms.create didn't create the expected alarm: ${JSON.stringify( + all + )}` + ); + } + + browser.alarms.onAlarm.addListener(alarm => { + if (alarm.name === "test-onAlarm") { + browser.test.succeed("Got the expected onAlarm event"); + } else { + browser.test.fail(`Got unexpected onAlarm event: ${alarm.name}`); + } + browser.test.sendMessage("test-completed"); + }); + + browser.alarms.create("test-onAlarm", { when: Date.now() + 1000 }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-completed"); + await extension.unload(); +}); + +add_task(async function test_sw_api_request_bgsw_runtime_onMessage() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-on-message@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function() { + browser.test.onMessage.addListener(msg => { + if (msg !== "extpage-send-message") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + browser.runtime.sendMessage("extpage-send-message"); + }); + }, + "sw.js": async function() { + browser.runtime.onMessage.addListener(msg => { + browser.test.sendMessage("bgsw-on-message", msg); + }); + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + extension.sendMessage("extpage-send-message"); + + const msg = await extension.awaitMessage("bgsw-on-message"); + equal(msg, "extpage-send-message", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +add_task(async function test_sw_api_request_bgsw_runtime_sendMessage() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-sendMessage@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function() { + browser.runtime.onMessage.addListener(msg => { + browser.test.sendMessage("extpage-on-message", msg); + }); + + browser.test.sendMessage("extpage-ready"); + }, + "sw.js": async function() { + browser.test.onMessage.addListener(msg => { + if (msg !== "bgsw-send-message") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + browser.runtime.sendMessage("bgsw-send-message"); + }); + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + await extension.awaitMessage("extpage-ready"); + extension.sendMessage("bgsw-send-message"); + + const msg = await extension.awaitMessage("extpage-on-message"); + equal(msg, "bgsw-send-message", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API requests that +// returns a runtinme.Port API object. +add_task(async function test_sw_api_request_bgsw_connnect_runtime_port() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function() { + browser.runtime.onConnect.addListener(port => { + browser.test.sendMessage("page-got-port-from-sw"); + port.postMessage("page-to-sw"); + }); + browser.test.sendMessage("page-waiting-port"); + }, + "sw.js": async function() { + browser.test.onMessage.addListener(msg => { + if (msg !== "connect-port") { + return; + } + const port = browser.runtime.connect(); + if (!port) { + browser.test.fail("Got an undefined port"); + } + port.onMessage.addListener((msg, portArgument) => { + browser.test.assertTrue( + port === portArgument, + "Got the expected runtime.Port instance" + ); + browser.test.sendMessage("test-done", msg); + }); + browser.test.sendMessage("sw-waiting-port-message"); + }); + + const portWithError = browser.runtime.connect(); + portWithError.onDisconnect.addListener(() => { + const portError = portWithError.error; + browser.test.sendMessage("port-error", { + isError: portError instanceof Error, + message: portError?.message, + }); + }); + + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + browser.test.sendMessage("ext-id", browser.runtime.id); + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + + const extId = await extension.awaitMessage("ext-id"); + equal(extId, extension.id, "Got the expected extension id"); + + const lastError = await extension.awaitMessage("port-error"); + Assert.deepEqual( + lastError, + { + isError: true, + message: "Could not establish connection. Receiving end does not exist.", + }, + "Got the expected lastError value" + ); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + await extension.awaitMessage("page-waiting-port"); + + info("bgsw connect port"); + extension.sendMessage("connect-port"); + await extension.awaitMessage("sw-waiting-port-message"); + info("bgsw waiting port message"); + await extension.awaitMessage("page-got-port-from-sw"); + info("page got port from sw, wait to receive event"); + const msg = await extension.awaitMessage("test-done"); + equal(msg, "page-to-sw", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +// Verify ExtensionAPIRequestHandler handling API events that should +// get a runtinme.Port API object as an event argument. +add_task(async function test_sw_api_request_bgsw_runtime_onConnect() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + permissions: [], + browser_specific_settings: { + gecko: { id: "test-bg-sw-onConnect@mochi.test" }, + }, + }, + files: { + "page.html": '<!DOCTYPE html><script src="page.js"></script>', + "page.js": async function() { + browser.test.onMessage.addListener(msg => { + if (msg !== "connect-port") { + return; + } + const port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.sendMessage("test-done", msg); + }); + browser.test.sendMessage("page-waiting-port-message"); + }); + }, + "sw.js": async function() { + try { + const extURL = browser.runtime.getURL("/"); + browser.test.sendMessage("ext-url", extURL); + + browser.runtime.onConnect.addListener(port => { + browser.test.sendMessage("bgsw-got-port-from-page"); + port.postMessage("sw-to-page"); + }); + browser.test.sendMessage("bgsw-waiting-port"); + } catch (err) { + browser.test.fail(`Error on runtime.onConnect: ${err}`); + } + }, + }, + }); + + await extension.startup(); + const extURL = await extension.awaitMessage("ext-url"); + equal( + extURL, + `moz-extension://${extension.uuid}/`, + "Got the expected extension url" + ); + await extension.awaitMessage("bgsw-waiting-port"); + + const extPage = await ExtensionTestUtils.loadContentPage( + `${extURL}/page.html`, + { extension } + ); + info("ext page connect port"); + extension.sendMessage("connect-port"); + + await extension.awaitMessage("page-waiting-port-message"); + info("page waiting port message"); + await extension.awaitMessage("bgsw-got-port-from-page"); + info("bgsw got port from page, page wait to receive event"); + const msg = await extension.awaitMessage("test-done"); + equal(msg, "sw-to-page", "Got the expected message"); + await extPage.close(); + await extension.unload(); +}); + +add_task(async function test_sw_runtime_lastError() { + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + background: { + service_worker: "sw.js", + }, + browser_specific_settings: { gecko: { id: "test-bg-sw@mochi.test" } }, + }, + files: { + "page.html": "<!DOCTYPE html><body></body>", + "sw.js": async function() { + browser.runtime.sendMessage(() => { + const lastError = browser.runtime.lastError; + if (!(lastError instanceof Error)) { + browser.test.fail( + `lastError isn't an Error instance: ${lastError}` + ); + } + browser.test.sendMessage("test-lastError-completed"); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-lastError-completed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js new file mode 100644 index 0000000000..bf2a2a485b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_errors.js @@ -0,0 +1,102 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Because the `mockExtensionAPI` is currently the only "mock" API that has +// WebIDL bindings, this is the only namespace we can use in our tests. There +// is no JSON schema for this namespace so we add one here that is tailored for +// our testing needs. +const API = class extends ExtensionAPI { + getAPI(context) { + return { + mockExtensionAPI: { + methodAsync: () => { + return "some-value"; + }, + }, + }; + } +}; + +const SCHEMA = [ + { + namespace: "mockExtensionAPI", + functions: [ + { + name: "methodAsync", + type: "function", + async: true, + parameters: [ + { + name: "arg", + type: "string", + enum: ["THE_ONLY_VALUE_ALLOWED"], + }, + ], + }, + ], + }, +]; + +add_setup(async function() { + await AddonTestUtils.promiseStartupManager(); + + // The blob:-URL registered in `registerModules()` below gets loaded at: + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + + ExtensionParent.apiManager.registerModules({ + mockExtensionAPI: { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["mockExtensionAPI"]], + url: URL.createObjectURL( + new Blob([`this.mockExtensionAPI = ${API.toString()}`]) + ), + }, + }); +}); + +add_task(async function test_schema_error_is_propagated_to_extension() { + await runExtensionAPITest("should throw an extension error", { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync("UNEXPECTED_VALUE"); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testError }) { + Assert.ok( + /Invalid enumeration value "UNEXPECTED_VALUE"/.test(testError.message) + ); + }, + }); +}); + +add_task(async function test_schema_error_no_error_with_expected_value() { + await runExtensionAPITest("should not throw any error", { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync("THE_ONLY_VALUE_ALLOWED"); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, undefined); + Assert.deepEqual(testResult, "some-value"); + }, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js new file mode 100644 index 0000000000..0f367af908 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_api_schema_formatters.js @@ -0,0 +1,99 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Because the `mockExtensionAPI` is currently the only "mock" API that has +// WebIDL bindings, this is the only namespace we can use in our tests. There +// is no JSON schema for this namespace so we add one here that is tailored for +// our testing needs. +const API = class extends ExtensionAPI { + getAPI(context) { + return { + mockExtensionAPI: { + methodAsync: files => { + return files; + }, + }, + }; + } +}; + +const SCHEMA = [ + { + namespace: "mockExtensionAPI", + functions: [ + { + name: "methodAsync", + type: "function", + async: true, + parameters: [ + { + name: "files", + type: "array", + items: { $ref: "manifest.ExtensionURL" }, + }, + ], + }, + ], + }, +]; + +add_setup(async function() { + await AddonTestUtils.promiseStartupManager(); + + // The blob:-URL registered in `registerModules()` below gets loaded at: + // https://searchfox.org/mozilla-central/rev/0fec57c05d3996cc00c55a66f20dd5793a9bfb5d/toolkit/components/extensions/ExtensionCommon.jsm#1649 + Services.prefs.setBoolPref( + "security.allow_parent_unrestricted_js_loads", + true + ); + + ExtensionParent.apiManager.registerModules({ + mockExtensionAPI: { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["mockExtensionAPI"]], + url: URL.createObjectURL( + new Blob([`this.mockExtensionAPI = ${API.toString()}`]) + ), + }, + }); +}); + +add_task(async function test_relative_urls() { + await runExtensionAPITest( + "should format arguments with the relativeUrl formatter", + { + backgroundScript() { + return browser.mockExtensionAPI.methodAsync([ + "script-1.js", + "script-2.js", + ]); + }, + mockAPIRequestHandler(policy, request) { + return this._handleAPIRequest_orig(policy, request); + }, + assertResults({ testResult, testError, extension }) { + Assert.deepEqual( + testResult, + [ + `moz-extension://${extension.uuid}/script-1.js`, + `moz-extension://${extension.uuid}/script-2.js`, + ], + "expected correct url" + ); + Assert.deepEqual(testError, undefined, "expected no error"); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js new file mode 100644 index 0000000000..0d88014f32 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/test_ext_webidl_runtime_port.js @@ -0,0 +1,220 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_method_return_runtime_port() { + await runExtensionAPITest("API method returns an ExtensionPort instance", { + backgroundScript({ testAsserts, testLog }) { + try { + browser.mockExtensionAPI.methodReturnsPort("port-create-error"); + throw new Error("methodReturnsPort should have raised an exception"); + } catch (err) { + testAsserts.equal( + err?.message, + "An unexpected error occurred", + "Got the expected error" + ); + } + const port = browser.mockExtensionAPI.methodReturnsPort( + "port-create-success" + ); + testAsserts.equal(!!port, true, "Got a port"); + testAsserts.equal( + typeof port.name, + "string", + "port.name should be a string" + ); + testAsserts.equal( + typeof port.sender, + "object", + "port.sender should be an object" + ); + testAsserts.equal( + typeof port.disconnect, + "function", + "port.disconnect method" + ); + testAsserts.equal( + typeof port.postMessage, + "function", + "port.postMessage method" + ); + testAsserts.equal( + typeof port.onDisconnect?.addListener, + "function", + "port.onDisconnect.addListener method" + ); + testAsserts.equal( + typeof port.onMessage?.addListener, + "function", + "port.onDisconnect.addListener method" + ); + return new Promise(resolve => { + let messages = []; + port.onDisconnect.addListener(() => resolve(messages)); + port.onMessage.addListener((...args) => { + messages.push(args); + }); + }); + }, + assertResults({ testError, testResult }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + Assert.deepEqual( + testResult, + [ + [1, 2], + [3, 4], + [5, 6], + ], + "Got the expected results" + ); + }, + mockAPIRequestHandler(policy, request) { + if (request.apiName == "methodReturnsPort") { + if (request.args[0] == "port-create-error") { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: "not-a-valid-port", + }; + } + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: { + portId: "port-id-1", + name: "a-port-name", + }, + }; + } else if (request.requestType == "addListener") { + if (request.apiObjectType !== "Port") { + throw new Error(`Unexpected objectType ${request}`); + } + + switch (request.apiName) { + case "onDisconnect": + this._onDisconnectCb = request.eventListener; + return; + case "onMessage": + Promise.resolve().then(async () => { + await request.eventListener.callListener([1, 2]); + await request.eventListener.callListener([3, 4]); + await request.eventListener.callListener([5, 6]); + this._onDisconnectCb.callListener([]); + }); + return; + } + } else if ( + request.requestType == "getProperty" && + request.apiObjectType == "Port" && + request.apiName == "sender" + ) { + return { + type: Ci.mozIExtensionAPIRequestResult.RETURN_VALUE, + value: { id: "fake-sender-id-prop" }, + }; + } + + throw new Error(`Unexpected request: ${request}`); + }, + }); +}); + +add_task(async function test_port_as_event_listener_eventListener_param() { + await runExtensionAPITest( + "API event eventListener received an ExtensionPort parameter", + { + backgroundScript({ testAsserts, testLog }) { + const api = browser.mockExtensionAPI; + let listener; + + return new Promise((resolve, reject) => { + testLog("addListener and wait for event to be fired"); + listener = port => { + try { + testAsserts.equal(!!port, true, "Got a port parameter"); + testAsserts.equal( + port.name, + "a-port-name-2", + "Got expected port.name value" + ); + testAsserts.equal( + typeof port.disconnect, + "function", + "port.disconnect method" + ); + testAsserts.equal( + typeof port.postMessage, + "function", + "port.disconnect method" + ); + port.onMessage.addListener((msg, portArg) => { + if (msg === "test-done") { + testLog("Got a port.onMessage event"); + testAsserts.equal( + portArg?.name, + "a-port-name-2", + "Got port as last argument" + ); + testAsserts.equal( + portArg === port, + true, + "Got the same port instance as expected" + ); + resolve(); + } else { + reject( + new Error( + `port.onMessage got an unexpected message: ${msg}` + ) + ); + } + }); + } catch (err) { + reject(err); + } + }; + api.onTestEvent.addListener(listener); + }); + }, + assertResults({ testError }) { + Assert.deepEqual(testError, null, "Got no error as expected"); + }, + mockAPIRequestHandler(policy, request) { + if ( + request.requestType == "addListener" && + request.apiName == "onTestEvent" + ) { + request.eventListener.callListener(["arg0", "arg1"], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { portId: "port-id-2", name: "a-port-name-2" }, + apiObjectPrepended: true, + }); + return; + } else if ( + request.requestType == "addListener" && + request.apiObjectType == "Port" && + request.apiObjectId == "port-id-2" + ) { + request.eventListener.callListener(["test-done"], { + apiObjectType: Ci.mozIExtensionListenerCallOptions.RUNTIME_PORT, + apiObjectDescriptor: { portId: "port-id-2", name: "a-port-name-2" }, + }); + return; + } + + throw new Error(`Unexpected request: ${request}`); + }, + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini new file mode 100644 index 0000000000..465f913917 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/webidl-api/xpcshell.ini @@ -0,0 +1,32 @@ +[DEFAULT] +head = ../head.js ../head_remote.js ../head_service_worker.js head_webidl_api.js +firefox-appdir = browser +tags = webextensions webextensions-webidl-api + +prefs = + # Enable support for the extension background service worker. + extensions.backgroundServiceWorker.enabled=true + # Enable Extensions API WebIDL bindings for extension windows. + extensions.webidl-api.enabled=true + # Enable ExtensionMockAPI WebIDL bindings used for unit tests + # related to the API request forwarding and not tied to a particular + # extension API. + extensions.webidl-api.expose_mock_interface=true + # 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 remote settings server URL to + # ensure that the IDB database isn't created in the first place. + services.settings.server=data:,#remote-settings-dummy/v1 + +# NOTE: these tests seems to be timing out because it takes too much time to +# run all tests and then fully exiting the test. +skip-if = os == "android" && verify + +[test_ext_webidl_api.js] +[test_ext_webidl_api_event_callback.js] +skip-if = + os == "android" && processor == "x86_64" && debug # Bug 1716308 +[test_ext_webidl_api_request_handler.js] +[test_ext_webidl_api_schema_errors.js] +[test_ext_webidl_api_schema_formatters.js] +[test_ext_webidl_runtime_port.js] 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..635c89dbbc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini @@ -0,0 +1,21 @@ +# Similar to xpcshell-common.ini, except tests here only run +# when e10s is enabled (with or without out-of-process extensions). + +[test_ext_webRequest_eventPage_StreamFilter.js] +[test_ext_webRequest_filterResponseData.js] +# tsan failure is for test_filter_301 timing out, bug 1674773 +skip-if = + tsan || os == "android" && debug + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + fission # Bug 1762638 +[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 + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + fission # Bug 1762638 + 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..ea22cd6dfa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini @@ -0,0 +1,424 @@ +[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_asyncAPICall_isHandlingUserInput.js] +[test_ext_background_api_injection.js] +skip-if = os == "android" # Bug 1700482 +[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] +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[test_ext_browsingData_cookies_cookieStoreId.js] +[test_ext_cache_api.js] +[test_ext_captivePortal.js] +# As with test_captive_portal_service.js, we use the same limits here. +skip-if = + appname == "thunderbird" + os == "android" # CP service is disabled on Android + os == "mac" && debug # 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" # CP service is disabled on Android, + os == "mac" && debug # macosx1014/debug due to 1564534 +run-sequentially = node server exceptions dont replay well +[test_ext_cookieBehaviors.js] +skip-if = + appname == "thunderbird" + os == "android" # Bug 1683730, Android: Bug 1700482 + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + tsan + fission # Bug 1762638 +[test_ext_cookies_errors.js] +[test_ext_cookies_firstParty.js] +skip-if = + appname == "thunderbird" + os == "android" # Android: Bug 1680132. + tsan +[test_ext_cookies_onChanged.js] +[test_ext_cookies_partitionKey.js] +[test_ext_cookies_samesite.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_content_security_policy.js] +skip-if = + os == "win" # Bug 1762638 +[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 + fission # Bug 1762638 +[test_ext_contentscript_context.js] +skip-if = + tsan # Bug 1683730 + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + fission # Bug 1762638 +[test_ext_contentscript_context_isolation.js] +skip-if = + tsan # Bug 1683730 + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + fission # Bug 1762638 +[test_ext_contentscript_create_iframe.js] +[test_ext_contentscript_csp.js] +run-sequentially = very high failure rate in parallel +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[test_ext_contentscript_css.js] +skip-if = + os == "linux" && fission # Bug 1762638 + os == "mac" && debug # Bug 1762638 +[test_ext_contentscript_dynamic_registration.js] +[test_ext_contentscript_exporthelpers.js] +[test_ext_contentscript_importmap.js] +[test_ext_contentscript_in_background.js] +skip-if = os == "android" # Bug 1700482 +[test_ext_contentscript_json_api.js] +[test_ext_contentscript_module_import.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_cors_mozextension.js] +[test_ext_csp_frame_ancestors.js] +[test_ext_csp_upgrade_requests.js] +[test_ext_debugging_utils.js] +[test_ext_dnr_allowAllRequests.js] +[test_ext_dnr_api.js] +[test_ext_dnr_dynamic_rules.js] +[test_ext_dnr_modifyHeaders.js] +[test_ext_dnr_private_browsing.js] +[test_ext_dnr_redirect_transform.js] +[test_ext_dnr_session_rules.js] +[test_ext_dnr_static_rules.js] +[test_ext_dnr_system_restrictions.js] +[test_ext_dnr_testMatchOutcome.js] +[test_ext_dnr_tabIds.js] +[test_ext_dnr_urlFilter.js] +[test_ext_dnr_webrequest.js] +[test_ext_dnr_without_webrequest.js] +[test_ext_dns.js] +skip-if = os == "android" # Android needs alternative for proxy.settings - bug 1723523 +[test_ext_downloads.js] +[test_ext_downloads_cookies.js] +skip-if = + os == "android" # downloads API needs to be implemented in GeckoView - bug 1538348 + win10_2004 # Bug 1718292 + win11_2009 # Bug 1797751 + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[test_ext_downloads_cookieStoreId.js] +skip-if = + os == "android" + win10_2004 # Bug 1718292 +[test_ext_downloads_download.js] +skip-if = + tsan # Bug 1683730 + appname == "thunderbird" + os == "android" + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[test_ext_downloads_eventpage.js] +skip-if = os == "android" +[test_ext_downloads_misc.js] +skip-if = + os == "android" + tsan # Bug 1683730 + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[test_ext_downloads_partitionKey.js] +skip-if = os == "android" +[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_idle.js] +[test_ext_eventpage_warning.js] +[test_ext_eventpage_settings.js] +[test_ext_experiments.js] +[test_ext_extension.js] +[test_ext_extension_page_navigated.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_nonPersistentCookies.js] +[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_containerIsolation.js] +[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_getBackgroundPage.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] +skip-if = + os == "win" && bits == 32 && fission && !debug # Bug 1762638; win7 issue +[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] +skip-if = os == "android" # Android: Bug 1700482 +[test_ext_sandbox_var.js] +[test_ext_sandboxed_resource.js] +[test_ext_schema.js] +[test_ext_script_filenames.js] +run-sequentially = very high failure rate in parallel +skip-if = + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +[test_ext_scripting_contentScripts.js] +[test_ext_scripting_contentScripts_css.js] +skip-if = + os == "linux" && debug && fission # Bug 1762638 + os == "mac" && debug && fission # Bug 1762638 +run-sequentially = very high failure rate in parallel +[test_ext_scripting_contentScripts_file.js] +[test_ext_scripting_mv2.js] +[test_ext_scripting_persistAcrossSessions.js] +[test_ext_scripting_startupCache.js] +[test_ext_scripting_updateContentScripts.js] +[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] +skip-if = os == "android" # Bug 1700482 +[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" # Sanitizer.jsm is not in toolkit. +[test_ext_storage_sync.js] +skip-if = os == "android" # Bug 1680132 ; SessionStoreFunctions.sys.mjs relies on SessionStore.sys.mjs that does not exist on Android. +[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_theme_experiments.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] +skip-if = os == "android" # Bug 1700482 +run-sequentially = very high failure rate in parallel +[test_ext_userScripts_exports.js] +run-sequentially = very high failure rate in parallel +[test_ext_userScripts_register.js] +skip-if = + os == "linux" && !fission # Bug 1763197 + os == "android" # Bug 1763197 +[test_ext_wasm.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] +skip-if = + os == "android" && processor == 'x86_64' # Bug 1683253 +[test_ext_webRequest_containerIsolation.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_redirectProperty.js] +skip-if = + os == "android" && processor == 'x86_64' # Bug 1683253 +[test_ext_webRequest_redirect_mozextension.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_webRequest_requestSize.js] +[test_ext_webRequest_restrictedHeaders.js] +[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_webSocket.js] +run-sequentially = very high failure rate in parallel +[test_ext_webRequest_webSocket.js] +skip-if = appname == "thunderbird" +[test_ext_xhr_capabilities.js] +[test_ext_xhr_cors.js] +run-sequentially = very high failure rate in parallel +[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_failover.js] +[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] +skip-if = os == "win" # bug 1802704 +[test_proxy_userContextId.js] +[test_site_permissions.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..ea1a3ae736 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini @@ -0,0 +1,70 @@ +[test_ext_i18n.js] +skip-if = (os == "win" && debug) || (os == "linux") +[test_ext_i18n_css.js] +skip-if = + os == "mac" && debug && fission # Bug 1762638 + (socketprocess_networking || fission) && (os == "linux" && debug) # Bug 1759035 +run-sequentially = very high failure rate in parallel +[test_ext_contentscript.js] +skip-if = + socketprocess_networking # Bug 1759035 +run-sequentially = very high failure rate in parallel +[test_ext_contentscript_errors.js] +skip-if = + socketprocess_networking # Bug 1759035 + os == "win" && os_version == "6.1" # Skip on Azure - frequent failure +run-sequentially = very high failure rate in parallel +[test_ext_contentscript_about_blank_start.js] +[test_ext_contentscript_canvas_tainting.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +run-sequentially = very high failure rate in parallel + +[test_ext_contentscript_permissions_change.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 + os == "linux" && tsan && fission # bug 1762638 +[test_ext_contentscript_permissions_fetch.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +[test_ext_contentscript_scriptCreated.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +[test_ext_contentscript_triggeringPrincipal.js] +skip-if = + os == "android" # Bug 1680132 + (os == "win" && debug) # Bug 1438796 + tsan # Bug 1612707 + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 + os == "linux" && fission && debug # Bug 1762638 +[test_ext_contentscript_xrays.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +run-sequentially = very high failure rate in parallel +[test_ext_contentScripts_register.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 + fission # Bug 1762638 +[test_ext_contexts_gc.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +run-sequentially = very high failure rate in parallel +[test_ext_adoption_with_xrays.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +[test_ext_adoption_with_private_field_xrays.js] +skip-if = !nightly_build + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +run-sequentially = very high failure rate in parallel +[test_ext_shadowdom.js] +skip-if = ccov && os == 'linux' # bug 1607581 + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +[test_ext_web_accessible_resources.js] +skip-if = + apple_silicon # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 + fission # Bug 1762638 +[test_ext_web_accessible_resources_matches.js] +skip-if = + os == "linux" && socketprocess_networking && !fission && debug # Bug 1759035 +run-sequentially = very high failure rate in parallel 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..b84e3354c5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini @@ -0,0 +1,30 @@ +[DEFAULT] +head = head.js head_e10s.js head_telemetry.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +dupe-manifest = +support-files = + data/** + xpcshell-content.ini +tags = webextensions webextensions-e10s + +# 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 remote settings server URL to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=data:,#remote-settings-dummy/v1 + +[include:xpcshell-common-e10s.ini] +skip-if = + socketprocess_networking # Bug 1759035 +[include:xpcshell-content.ini] +skip-if = + socketprocess_networking && fission # Bug 1759035 + +# 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..af26762346 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini @@ -0,0 +1,21 @@ +[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 = + +# 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 remote settings server URL to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=data:,#remote-settings-dummy/v1 + +# 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..b6055bca46 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini @@ -0,0 +1,42 @@ +[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" + os == "win" && socketprocess_networking && fission # Bug 1759035 + os == "mac" && socketprocess_networking && fission # Bug 1759035 + # I would put linux here, but debug has too many chunks and only runs this manifest, so I need 1 test to pass +dupe-manifest = +support-files = + data/** + head_dnr.js + xpcshell-content.ini +tags = webextensions remote-webextensions + +# 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 remote settings server URL to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=data:,#remote-settings-dummy/v1 + +[include:xpcshell-common.ini] +skip-if = + os == "linux" && socketprocess_networking # Bug 1759035 +[include:xpcshell-common-e10s.ini] +skip-if = + os == "linux" && socketprocess_networking # Bug 1759035 +[include:xpcshell-content.ini] +skip-if = + os == "linux" && socketprocess_networking # Bug 1759035 +[test_ext_contentscript_perf_observers.js] # Inexplicably, PerformanceObserver used in the test doesn't fire in non-e10s mode. +skip-if = tsan + os == "linux" && socketprocess_networking # Bug 1759035 +[test_ext_contentscript_xorigin_frame.js] +skip-if = + os == "linux" && socketprocess_networking # Bug 1759035 +[test_WebExtensionContentScript.js] +[test_ext_ipcBlob.js] +skip-if = os == 'android' && processor == 'x86_64' + os == "linux" && socketprocess_networking # Bug 1759035 diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini new file mode 100644 index 0000000000..12346a0c75 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-serviceworker.ini @@ -0,0 +1,31 @@ +[DEFAULT] +head = head.js head_remote.js head_e10s.js head_telemetry.js head_sync.js head_storage.js head_service_worker.js +tail = +firefox-appdir = browser +skip-if = os == "android" +dupe-manifest = true +support-files = + data/** +tags = webextensions sw-webextensions +run-sequentially = Bug 1760041 pass logged after tests when running multiple ini files + +prefs = + extensions.backgroundServiceWorker.enabled=true + extensions.backgroundServiceWorker.forceInTestExtension=true + extensions.webextensions.remote=true + +[test_ext_alarms.js] +[test_ext_alarms_does_not_fire.js] +[test_ext_alarms_periodic.js] +[test_ext_alarms_replaces.js] +[test_ext_background_service_worker.js] +[test_ext_contentscript_dynamic_registration.js] +[test_ext_runtime_getBackgroundPage.js] +[test_ext_scripting_contentScripts.js] +[test_ext_scripting_contentScripts_css.js] +skip-if = + os == "linux" && debug && fission # Bug 1762638 + os == "mac" && debug && fission # Bug 1762638 +run-sequentially = very high failure rate in parallel +[test_ext_scripting_contentScripts_file.js] +[test_ext_scripting_updateContentScripts.js] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..a788e53a88 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini @@ -0,0 +1,99 @@ +[DEFAULT] +head = head.js head_telemetry.js head_sync.js head_storage.js +firefox-appdir = browser +dupe-manifest = +support-files = + data/** + head_dnr.js + xpcshell-content.ini +tags = webextensions in-process-webextensions condprof + +# 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 remote settings server URL to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=data:,#remote-settings-dummy/v1 + +# 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_ExtensionShortcutKeyMap.js] +[test_ExtensionStorageSync_migration_kinto.js] +skip-if = os == 'android' # Not shipped on Android + condprof # Bug 1769184 - by design for now +[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_clear_cached_resources.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] +head = head.js head_schemas.js +[test_ext_schemas_roots.js] +[test_ext_schemas_async.js] +[test_ext_schemas_allowed_contexts.js] +[test_ext_schemas_interactive.js] +[test_ext_schemas_manifest_permissions.js] +skip-if = + condprof # Bug 1769184 - by design for now +[test_ext_schemas_privileged.js] +skip-if = + condprof # Bug 1769184 - by design for now +[test_ext_schemas_revoke.js] +[test_ext_schemas_versioned.js] +head = head.js head_schemas.js +[test_ext_secfetch.js] +skip-if = + socketprocess_networking # Bug 1759035 +run-sequentially = very high failure rate in parallel +[test_ext_shared_array_buffer.js] +[test_ext_startup_cache_telemetry.js] +[test_ext_test_mock.js] +[test_ext_test_wrapper.js] +[test_ext_unknown_permissions.js] +[test_ext_webRequest_urlclassification.js] +[test_extension_permissions_migration.js] +skip-if = + condprof # Bug 1769184 - by design for now +[test_load_all_api_modules.js] +[test_locale_converter.js] +[test_locale_data.js] + +[test_ext_runtime_sendMessage_args.js] + +[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 |