diff options
Diffstat (limited to 'toolkit/components/extensions/test/xpcshell')
287 files changed, 54625 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..3622fff4f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = { + env: { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + webextensions: true, + }, +}; diff --git a/toolkit/components/extensions/test/xpcshell/data/dummy_page.html b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html new file mode 100644 index 0000000000..c1c9a4e043 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/dummy_page.html @@ -0,0 +1,7 @@ +<!DOCTYPE html> + +<html> +<body> +<p>Page</p> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/empty_file_download.txt diff --git a/toolkit/components/extensions/test/xpcshell/data/file download.txt b/toolkit/components/extensions/test/xpcshell/data/file download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html new file mode 100644 index 0000000000..b2cf48f9e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_page2.html @@ -0,0 +1,25 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<link rel="stylesheet" href="file_style_good.css"> +<link rel="stylesheet" href="file_style_bad.css"> +<link rel="stylesheet" href="file_style_redirect.css"> +</head> +<body> + +<div class="test">Sample text</div> + +<img id="img_good" src="file_image_good.png"> +<img id="img_bad" src="file_image_bad.png"> +<img id="img_redirect" src="file_image_redirect.png"> + +<script src="file_script_good.js"></script> +<script src="file_script_bad.js"></script> +<script src="file_script_redirect.js"></script> + +<script src="nonexistent_script_url.js"></script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html new file mode 100644 index 0000000000..f6b5142c4d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script src="http://example.org/data/file_WebRequest_permission_original.js"></script> +<script> +"use strict"; + +window.parent.postMessage({ + page: "original", + script: window.testScript, +}, "*"); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js new file mode 100644 index 0000000000..2981108b64 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_original.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "original"; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html new file mode 100644 index 0000000000..0979593f7b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.html @@ -0,0 +1,19 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script src="http://example.org/data/file_WebRequest_permission_original.js"></script> +<script> +"use strict"; + +window.parent.postMessage({ + page: "redirected", + script: window.testScript, +}, "*"); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js new file mode 100644 index 0000000000..06fd42aa40 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_WebRequest_permission_redirected.js @@ -0,0 +1,2 @@ +"use strict"; +window.testScript = "redirected"; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html b/toolkit/components/extensions/test/xpcshell/data/file_csp.html new file mode 100644 index 0000000000..9f5cf92f5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> +<img id="bad-image" src="http://example.org/data/file_image_bad.png"> +<script id="bad-script" src="http://example.org/data/file_script_bad.js"></script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ new file mode 100644 index 0000000000..4c6fa3c26a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_csp.html^headers^ @@ -0,0 +1 @@ +Content-Security-Policy: default-src 'self' diff --git a/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html new file mode 100644 index 0000000000..c74dec5f5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_do_load_script_subresource.html @@ -0,0 +1,9 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> +<script src="http://example.net/intercept_by_webRequest.js"></script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_open.html b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html new file mode 100644 index 0000000000..dae5e90667 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_open.html @@ -0,0 +1,21 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + + <iframe id="iframe"></iframe> + + <script type="text/javascript"> + "use strict"; + addEventListener("load", () => { + let iframe = document.getElementById("iframe"); + let doc = iframe.contentDocument; + doc.open("text/html"); + doc.write("Hello."); + doc.close(); + }, {once: true}); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_document_write.html b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html new file mode 100644 index 0000000000..fbae3d6d76 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_document_write.html @@ -0,0 +1,35 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + <iframe id="iframe"></iframe> + + <script type="text/javascript"> + "use strict"; + addEventListener("load", () => { + // Send a heap-minimize observer notification so our script cache is + // cleared, and our content script isn't available for synchronous + // insertion. + window.dispatchEvent(new CustomEvent("MozHeapMinimize")); + + let iframe = document.getElementById("iframe"); + let doc = iframe.contentDocument; + doc.open("text/html"); + // We need to do two writes here. The first creates the document element, + // which normally triggers parser blocking. The second triggers the + // creation of the element we're about to query for, which would normally + // happen asynchronously if the parser were blocked. + doc.write("<div id=meh>"); + doc.write("<div id=beer></div>"); + + let elem = doc.getElementById("beer"); + top.postMessage(elem instanceof HTMLDivElement ? "ok" : "fail", + "*"); + + doc.close(); + }, {once: true}); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.html b/toolkit/components/extensions/test/xpcshell/data/file_download.html new file mode 100644 index 0000000000..d970c63259 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div>Download HTML File</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_download.txt b/toolkit/components/extensions/test/xpcshell/data/file_download.txt new file mode 100644 index 0000000000..6293c7af79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_download.txt @@ -0,0 +1 @@ +This is a sample file used in download tests. diff --git a/toolkit/components/extensions/test/xpcshell/data/file_iframe.html b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html new file mode 100644 index 0000000000..0cd68be586 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_iframe.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Iframe document</title> +</head> +<body> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_bad.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_good.png b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png Binary files differnew file mode 100644 index 0000000000..769c636340 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_good.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_image_redirect.png diff --git a/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html new file mode 100644 index 0000000000..387b5285f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_page_xhr.html @@ -0,0 +1,34 @@ +<!DOCTYPE html> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +addEventListener("message", async function(event) { + const url = new URL("/return_headers.sjs", location).href; + + const webpageFetchResult = await fetch(url).then(res => res.json()); + const webpageXhrResult = await new Promise(resolve => { + const req = new XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", () => resolve(JSON.parse(req.responseText)), + {once: true}); + req.addEventListener("error", () => resolve({error: "webpage xhr failed to complete"}), + {once: true}); + req.send(); + }); + + postMessage({ + type: "testPageGlobals", + webpageFetchResult, + webpageXhrResult, + }, "*"); +}, {once: true}); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html new file mode 100644 index 0000000000..6f1bb4648b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_permission_xhr.html @@ -0,0 +1,61 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<script> +"use strict"; + +/* globals privilegedFetch, privilegedXHR */ +/* eslint-disable mozilla/balanced-listeners */ + +addEventListener("message", function rcv(event) { + removeEventListener("message", rcv, false); + + function assertTrue(condition, description) { + postMessage({msg: "assertTrue", condition, description}, "*"); + } + + function assertThrows(func, expectedError, msg) { + try { + func(); + } catch (e) { + assertTrue(expectedError.test(e), msg + ": threw " + e); + return; + } + + assertTrue(false, "Function did not throw, " + + "expected error should have matched " + expectedError); + } + + function passListener() { + assertTrue(true, "Content XHR has no elevated privileges"); + postMessage({"msg": "finish"}, "*"); + } + + function failListener() { + assertTrue(false, "Content XHR has no elevated privileges"); + postMessage({"msg": "finish"}, "*"); + } + + assertThrows(function() { new privilegedXHR(); }, + /Permission denied to access object/, + "Content should not be allowed to construct a privileged XHR constructor"); + + assertThrows(function() { new privilegedFetch(); }, + / is not a constructor/, + "Content should not be allowed to construct a privileged fetch() constructor"); + + let req = new XMLHttpRequest(); + req.addEventListener("load", failListener); + req.addEventListener("error", passListener); + req.open("GET", "http://example.org/example.txt"); + req.send(); +}, false); +</script> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html new file mode 100644 index 0000000000..258f7058d9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_privilege_escalation.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + <script type="text/javascript"> + "use strict"; + throw new Error(`WebExt Privilege Escalation: typeof(browser) = ${typeof(browser)}`); + </script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample.html b/toolkit/components/extensions/test/xpcshell/data/file_sample.html new file mode 100644 index 0000000000..a20e49a1f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample.html @@ -0,0 +1,12 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html new file mode 100644 index 0000000000..9f5c5d5a6a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_sample_registered_styles.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +</head> +<body> + +<div id="registered-extension-url-style">Registered Extension URL style</div> +<div id="registered-extension-text-style">Registered Extension Text style</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script.html b/toolkit/components/extensions/test/xpcshell/data/file_script.html new file mode 100644 index 0000000000..8d192b7d8e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script.html @@ -0,0 +1,14 @@ +<!DOCTYPE HTML> + +<html> +<head> +<meta charset="utf-8"> +<script type="application/javascript" src="file_script_good.js"></script> +<script type="application/javascript" src="file_script_bad.js"></script> +</head> +<body> + +<div id="test">Sample text</div> + +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js new file mode 100644 index 0000000000..ff4572865b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_bad.js @@ -0,0 +1,12 @@ +"use strict"; + +window.failure = true; +window.addEventListener( + "load", + () => { + let el = document.createElement("div"); + el.setAttribute("id", "bad"); + document.body.appendChild(el); + }, + { once: true } +); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_good.js b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js new file mode 100644 index 0000000000..bf47fb36d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_good.js @@ -0,0 +1,12 @@ +"use strict"; + +window.success = window.success ? window.success + 1 : 1; +window.addEventListener( + "load", + () => { + let el = document.createElement("div"); + el.setAttribute("id", "good"); + document.body.appendChild(el); + }, + { once: true } +); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js new file mode 100644 index 0000000000..c425122c71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_redirect.js @@ -0,0 +1,3 @@ +"use strict"; + +window.failure = true; diff --git a/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js new file mode 100644 index 0000000000..24a26cb8d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_script_xhr.js @@ -0,0 +1,9 @@ +"use strict"; + +var request = new XMLHttpRequest(); +request.open( + "get", + "http://example.com/browser/toolkit/modules/tests/browser/xhr_resource", + false +); +request.send(); diff --git a/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html new file mode 100644 index 0000000000..c4e7db14e7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_shadowdom.html @@ -0,0 +1,13 @@ +<!DOCTYPE HTML> +<html> +<head> +<meta charset="utf-8"> +</head> +<body> +<div id="host">host</div> +<script> + "use strict"; + document.getElementById("host").attachShadow({mode: "closed"}); +</script> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_bad.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_good.css b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css new file mode 100644 index 0000000000..46f9774b5f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_good.css @@ -0,0 +1,3 @@ +#test { + color: red; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css new file mode 100644 index 0000000000..8dbc8dc7a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_style_redirect.css @@ -0,0 +1,3 @@ +#test { + color: green !important; +} diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css new file mode 100644 index 0000000000..6a9140d97e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.css @@ -0,0 +1 @@ +:root { color: green; } diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html new file mode 100644 index 0000000000..6d6d187a27 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache.html @@ -0,0 +1,3 @@ +<!doctype html> +<meta charset=utf-8> +<link rel=stylesheet href=file_stylesheet_cache.css> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html new file mode 100644 index 0000000000..07a4324c44 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_stylesheet_cache_2.html @@ -0,0 +1,19 @@ +<!doctype html> +<meta charset=utf-8> +<!-- The first one should hit the cache, the second one should not. --> +<link rel=stylesheet href=file_stylesheet_cache.css> +<script> + "use strict"; + // This script guarantees that the load of the above stylesheet has happened + // by now. + // + // Now we can go ahead and load the other one programmatically. It's + // important that we don't just throw a <link> in the markup below to + // guarantee + // that the load happens afterwards (that is, to cheat the parser's speculative + // load mechanism). + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "file_stylesheet_cache.css?2"; + document.head.appendChild(link); +</script> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html new file mode 100644 index 0000000000..d93813d0f5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_toplevel.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Top-level frame document</title> +</head> +<body> + <iframe src="file_iframe.html"></iframe> + <iframe src="about:blank"></iframe> + <iframe srcdoc="Iframe srcdoc"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html new file mode 100644 index 0000000000..199c2ce4d4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/file_with_xorigin_frame.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title>Document with example.org frame</title> +</head> +<body> + <iframe src="http://example.org/data/file_iframe.html"></iframe> +</body> +</html> diff --git a/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz Binary files differnew file mode 100644 index 0000000000..9eb8d73d50 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/lorem.html.gz diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif Binary files differnew file mode 100644 index 0000000000..baf8166dae --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/pixel_green.gif diff --git a/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif Binary files differnew file mode 100644 index 0000000000..48f97f74bd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/data/pixel_red.gif diff --git a/toolkit/components/extensions/test/xpcshell/head.js b/toolkit/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..4608f77bd6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head.js @@ -0,0 +1,277 @@ +"use strict"; + +/* exported createHttpServer, cleanupDir, clearCache, promiseConsoleOutput, + promiseQuotaManagerServiceReset, promiseQuotaManagerServiceClear, + runWithPrefs, testEnv, withHandlingUserInput, resetHandlingUserInput */ + +var { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +var { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +var { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +var { + clearInterval, + clearTimeout, + setInterval, + setIntervalWithTarget, + setTimeout, + setTimeoutWithTarget, +} = ChromeUtils.import("resource://gre/modules/Timer.jsm"); +var { AddonTestUtils, MockAsyncShutdown } = ChromeUtils.import( + "resource://testing-common/AddonTestUtils.jsm" +); + +// eslint-disable-next-line no-unused-vars +XPCOMUtils.defineLazyModuleGetters(this, { + ContentTask: "resource://testing-common/ContentTask.jsm", + Extension: "resource://gre/modules/Extension.jsm", + ExtensionData: "resource://gre/modules/Extension.jsm", + ExtensionParent: "resource://gre/modules/ExtensionParent.jsm", + ExtensionTestUtils: "resource://testing-common/ExtensionXPCShellUtils.jsm", + FileUtils: "resource://gre/modules/FileUtils.jsm", + MessageChannel: "resource://gre/modules/MessageChannel.jsm", + NetUtil: "resource://gre/modules/NetUtil.jsm", + PromiseTestUtils: "resource://testing-common/PromiseTestUtils.jsm", + Schemas: "resource://gre/modules/Schemas.jsm", +}); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); + +// These values may be changed in later head files and tested in check_remote +// below. +Services.prefs.setBoolPref("browser.tabs.remote.autostart", false); +Services.prefs.setBoolPref("extensions.webextensions.remote", false); +const testEnv = { + expectRemote: false, +}; + +add_task(function check_remote() { + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + testEnv.expectRemote, + "useRemoteWebExtensions matches" + ); + Assert.equal( + WebExtensionPolicy.isExtensionProcess, + !testEnv.expectRemote, + "testing from extension process" + ); +}); + +ExtensionTestUtils.init(this); + +var createHttpServer = (...args) => { + AddonTestUtils.maybeInit(this); + return AddonTestUtils.createHttpServer(...args); +}; + +if (AppConstants.platform === "android") { + Services.io.offline = true; +} + +/** + * Clears the HTTP and content image caches. + */ +function clearCache() { + Services.cache2.clear(); + + let imageCache = Cc["@mozilla.org/image/tools;1"] + .getService(Ci.imgITools) + .getImgCacheForDocument(null); + imageCache.clearCache(false); +} + +var promiseConsoleOutput = async function(task) { + const DONE = `=== console listener ${Math.random()} done ===`; + + let listener; + let messages = []; + let awaitListener = new Promise(resolve => { + listener = msg => { + if (msg == DONE) { + resolve(); + } else { + void (msg instanceof Ci.nsIConsoleMessage); + void (msg instanceof Ci.nsIScriptError); + messages.push(msg); + } + }; + }); + + Services.console.registerListener(listener); + try { + let result = await task(); + + Services.console.logStringMessage(DONE); + await awaitListener; + + return { messages, result }; + } finally { + Services.console.unregisterListener(listener); + } +}; + +// Attempt to remove a directory. If the Windows OS is still using the +// file sometimes remove() will fail. So try repeatedly until we can +// remove it or we give up. +function cleanupDir(dir) { + let count = 0; + return new Promise((resolve, reject) => { + function tryToRemoveDir() { + count += 1; + try { + dir.remove(true); + } catch (e) { + // ignore + } + if (!dir.exists()) { + return resolve(); + } + if (count >= 25) { + return reject(`Failed to cleanup directory: ${dir}`); + } + setTimeout(tryToRemoveDir, 100); + } + tryToRemoveDir(); + }); +} + +// Run a test with the specified preferences and then restores their initial values +// right after the test function run (whether it passes or fails). +async function runWithPrefs(prefsToSet, testFn) { + const setPrefs = prefs => { + for (let [pref, value] of prefs) { + if (value === undefined) { + // Clear any pref that didn't have a user value. + info(`Clearing pref "${pref}"`); + Services.prefs.clearUserPref(pref); + continue; + } + + info(`Setting pref "${pref}": ${value}`); + switch (typeof value) { + case "boolean": + Services.prefs.setBoolPref(pref, value); + break; + case "number": + Services.prefs.setIntPref(pref, value); + break; + case "string": + Services.prefs.setStringPref(pref, value); + break; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + } + }; + + const getPrefs = prefs => { + return prefs.map(([pref, value]) => { + info(`Getting initial pref value for "${pref}"`); + if (!Services.prefs.prefHasUserValue(pref)) { + // Check if the pref doesn't have a user value. + return [pref, undefined]; + } + switch (typeof value) { + case "boolean": + return [pref, Services.prefs.getBoolPref(pref)]; + case "number": + return [pref, Services.prefs.getIntPref(pref)]; + case "string": + return [pref, Services.prefs.getStringPref(pref)]; + default: + throw new Error("runWithPrefs doesn't support this pref type yet"); + } + }); + }; + + let initialPrefsValues = []; + + try { + initialPrefsValues = getPrefs(prefsToSet); + + setPrefs(prefsToSet); + + await testFn(); + } finally { + info("Restoring initial preferences values on exit"); + setPrefs(initialPrefsValues); + } +} + +// "Handling User Input" test helpers. + +let extensionHandlers = new WeakSet(); + +function handlingUserInputFrameScript() { + /* globals content */ + // eslint-disable-next-line no-shadow + const { MessageChannel } = ChromeUtils.import( + "resource://gre/modules/MessageChannel.jsm" + ); + + let handle; + MessageChannel.addListener(this, "ExtensionTest:HandleUserInput", { + receiveMessage({ name, data }) { + if (data) { + handle = content.windowUtils.setHandlingUserInput(true); + } else if (handle) { + handle.destruct(); + handle = null; + } + }, + }); +} + +// If you use withHandlingUserInput then restart the addon manager, +// you need to reset this before using withHandlingUserInput again. +function resetHandlingUserInput() { + extensionHandlers = new WeakSet(); +} + +async function withHandlingUserInput(extension, fn) { + let { messageManager } = extension.extension.groupFrameLoader; + + if (!extensionHandlers.has(extension)) { + messageManager.loadFrameScript( + `data:,(${encodeURI(handlingUserInputFrameScript)}).call(this)`, + false, + true + ); + extensionHandlers.add(extension); + } + + await MessageChannel.sendMessage( + messageManager, + "ExtensionTest:HandleUserInput", + true + ); + await fn(); + await MessageChannel.sendMessage( + messageManager, + "ExtensionTest:HandleUserInput", + false + ); +} + +// QuotaManagerService test helpers. + +function promiseQuotaManagerServiceReset() { + info("Calling QuotaManagerService.reset to enforce new test storage limits"); + return new Promise(resolve => { + Services.qms.reset().callback = resolve; + }); +} + +function promiseQuotaManagerServiceClear() { + info( + "Calling QuotaManagerService.clear to empty the test data and refresh test storage limits" + ); + return new Promise(resolve => { + Services.qms.clear().callback = resolve; + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_e10s.js b/toolkit/components/extensions/test/xpcshell/head_e10s.js new file mode 100644 index 0000000000..196afae7c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_e10s.js @@ -0,0 +1,8 @@ +"use strict"; + +/* globals ExtensionTestUtils */ + +// xpcshell disables e10s by default. Turn it on. +Services.prefs.setBoolPref("browser.tabs.remote.autostart", true); + +ExtensionTestUtils.remoteContentScripts = true; diff --git a/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js new file mode 100644 index 0000000000..01f16ec54c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_legacy_ep.js @@ -0,0 +1,13 @@ +"use strict"; + +// Bug 1646182: Test the legacy ExtensionPermission backend until we fully +// migrate to rkv + +{ + const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" + ); + + ExtensionPermissions._useLegacyStorageBackend = true; + ExtensionPermissions._uninit(); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_native_messaging.js b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js new file mode 100644 index 0000000000..e0b977c22c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_native_messaging.js @@ -0,0 +1,153 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals AppConstants, FileUtils */ +/* exported getSubprocessCount, setupHosts, waitForSubprocessExit */ + +ChromeUtils.defineModuleGetter( + this, + "MockRegistry", + "resource://testing-common/MockRegistry.jsm" +); +ChromeUtils.defineModuleGetter(this, "OS", "resource://gre/modules/osfile.jsm"); + +let { Subprocess, SubprocessImpl } = ChromeUtils.import( + "resource://gre/modules/Subprocess.jsm", + null +); + +// It's important that we use a space in this directory name to make sure we +// correctly handle executing batch files with spaces in their path. +let tmpDir = FileUtils.getDir("TmpD", ["Native Messaging"]); +tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +const TYPE_SLUG = + AppConstants.platform === "linux" + ? "native-messaging-hosts" + : "NativeMessagingHosts"; +OS.File.makeDir(OS.Path.join(tmpDir.path, TYPE_SLUG)); + +registerCleanupFunction(() => { + tmpDir.remove(true); +}); + +function getPath(filename) { + return OS.Path.join(tmpDir.path, TYPE_SLUG, filename); +} + +const ID = "native@tests.mozilla.org"; + +async function setupHosts(scripts) { + const PERMS = { unixMode: 0o755 }; + + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + const pythonPath = await Subprocess.pathSearch(env.get("PYTHON")); + + async function writeManifest(script, scriptPath, path) { + let body = `#!${pythonPath} -u\n${script.script}`; + + await OS.File.writeAtomic(scriptPath, body); + await OS.File.setPermissions(scriptPath, PERMS); + + let manifest = { + name: script.name, + description: script.description, + path, + type: "stdio", + allowed_extensions: [ID], + }; + + let manifestPath = getPath(`${script.name}.json`); + await OS.File.writeAtomic(manifestPath, JSON.stringify(manifest)); + + return manifestPath; + } + + switch (AppConstants.platform) { + case "macosx": + case "linux": + let dirProvider = { + getFile(property) { + if (property == "XREUserNativeManifests") { + return tmpDir.clone(); + } else if (property == "XRESysNativeManifests") { + return tmpDir.clone(); + } + return null; + }, + }; + + Services.dirsvc.registerProvider(dirProvider); + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + }); + + for (let script of scripts) { + let path = getPath(`${script.name}.py`); + + await writeManifest(script, path, path); + } + break; + + case "win": + const REGKEY = String.raw`Software\Mozilla\NativeMessagingHosts`; + + let registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); + + for (let script of scripts) { + let { scriptExtension = "bat" } = script; + + // It's important that we use a space in this filename. See directory + // name comment above. + let batPath = getPath(`batch ${script.name}.${scriptExtension}`); + let scriptPath = getPath(`${script.name}.py`); + + let batBody = `@ECHO OFF\n${pythonPath} -u "${scriptPath}" %*\n`; + await OS.File.writeAtomic(batPath, batBody); + + // Create absolute and relative path versions of the entry. + for (let [name, path] of [ + [script.name, batPath], + [`relative.${script.name}`, OS.Path.basename(batPath)], + ]) { + script.name = name; + let manifestPath = await writeManifest(script, scriptPath, path); + + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGKEY}\\${script.name}`, + "", + manifestPath + ); + } + } + break; + + default: + ok( + false, + `Native messaging is not supported on ${AppConstants.platform}` + ); + } +} + +function getSubprocessCount() { + return SubprocessImpl.Process.getWorker() + .call("getProcesses", []) + .then(result => result.size); +} +function waitForSubprocessExit() { + return SubprocessImpl.Process.getWorker() + .call("waitForNoProcesses", []) + .then(() => { + // Return to the main event loop to give IO handlers enough time to consume + // their remaining buffered input. + return new Promise(resolve => setTimeout(resolve, 0)); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_remote.js b/toolkit/components/extensions/test/xpcshell/head_remote.js new file mode 100644 index 0000000000..f9c31144c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_remote.js @@ -0,0 +1,7 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.webextensions.remote", true); +Services.prefs.setIntPref("dom.ipc.keepProcessesAlive.extension", 1); + +/* globals testEnv */ +testEnv.expectRemote = true; // tested in head_test.js diff --git a/toolkit/components/extensions/test/xpcshell/head_storage.js b/toolkit/components/extensions/test/xpcshell/head_storage.js new file mode 100644 index 0000000000..09a5b45b0e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_storage.js @@ -0,0 +1,1227 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from head.js */ + +const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + +// Test implementations and utility functions that are used against multiple +// storage areas (eg, a test which is run against browser.storage.local and +// browser.storage.sync, or a test against browser.storage.sync but needs to +// be run against both the kinto and rust implementations.) + +/** + * Utility function to ensure that all supported APIs for getting are + * tested. + * + * @param {string} areaName + * either "local" or "sync" according to what we want to test + * @param {string} prop + * "key" to look up using the storage API + * @param {Object} value + * "value" to compare against + */ +async function checkGetImpl(areaName, prop, value) { + let storage = browser.storage[areaName]; + + let data = await storage.get(); + browser.test.assertEq( + value, + data[prop], + `unspecified getter worked for ${prop} in ${areaName}` + ); + + data = await storage.get(null); + browser.test.assertEq( + value, + data[prop], + `null getter worked for ${prop} in ${areaName}` + ); + + data = await storage.get(prop); + browser.test.assertEq( + value, + data[prop], + `string getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `string getter should return an object with a single property` + ); + + data = await storage.get([prop]); + browser.test.assertEq( + value, + data[prop], + `array getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `array getter with a single key should return an object with a single property` + ); + + data = await storage.get({ [prop]: undefined }); + browser.test.assertEq( + value, + data[prop], + `object getter worked for ${prop} in ${areaName}` + ); + browser.test.assertEq( + Object.keys(data).length, + 1, + `object getter with a single key should return an object with a single property` + ); +} + +function test_config_flag_needed() { + async function testFn() { + function background() { + let promises = []; + let apiTests = [ + { method: "get", args: ["foo"] }, + { method: "set", args: [{ foo: "bar" }] }, + { method: "remove", args: ["foo"] }, + { method: "clear", args: [] }, + ]; + apiTests.forEach(testDef => { + promises.push( + browser.test.assertRejects( + browser.storage.sync[testDef.method](...testDef.args), + "Please set webextensions.storage.sync.enabled to true in about:config", + `storage.sync.${testDef.method} is behind a flag` + ) + ); + }); + + Promise.all(promises).then(() => browser.test.notifyPass("flag needed")); + } + + ok( + !Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to false" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("flag needed"); + await extension.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, false]], testFn); +} + +function test_sync_reloading_extensions_works() { + async function testFn() { + // Just some random extension ID that we can re-use + const extensionId = "my-extension-id@1"; + + function loadExtension() { + function background() { + browser.storage.sync.set({ a: "b" }).then(() => { + browser.test.notifyPass("set-works"); + }); + } + + return ExtensionTestUtils.loadExtension( + { + manifest: { + permissions: ["storage"], + }, + background: `(${background})()`, + }, + extensionId + ); + } + + ok( + Services.prefs.getBoolPref(STORAGE_SYNC_PREF, false), + "The `${STORAGE_SYNC_PREF}` should be set to true" + ); + + let extension1 = loadExtension(); + + await extension1.startup(); + await extension1.awaitFinish("set-works"); + await extension1.unload(); + + let extension2 = loadExtension(); + + await extension2.startup(); + await extension2.awaitFinish("set-works"); + await extension2.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, true]], testFn); +} + +async function test_background_page_storage(testAreaName) { + async function backgroundScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { + gResolve = resolve; + }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq( + expectedAreaName, + areaName, + "Expected area name received by listener" + ); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue( + obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertTrue( + obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertEq( + obj1[prop].oldValue, + obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})` + ); + browser.test.assertEq( + obj1[prop].newValue, + obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})` + ); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + // Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1645598 + async function testNonExistingKeys(storage, storageAreaDesc) { + let data = await storage.get({ test6: 6 }); + browser.test.assertEq( + `{"test6":6}`, + JSON.stringify(data), + `Use default value when not stored for ${storageAreaDesc}` + ); + + data = await storage.get({ test6: null }); + browser.test.assertEq( + `{"test6":null}`, + JSON.stringify(data), + `Use default value, even if null for ${storageAreaDesc}` + ); + + data = await storage.get("test6"); + browser.test.assertEq( + `{}`, + JSON.stringify(data), + `Empty result if key is not found for ${storageAreaDesc}` + ); + + data = await storage.get(["test6", "test7"]); + browser.test.assertEq( + `{}`, + JSON.stringify(data), + `Empty result if list of keys is not found for ${storageAreaDesc}` + ); + } + + async function testFalseyValues(areaName) { + let storage = browser.storage[areaName]; + const dataInitial = { + "test-falsey-value-bool": false, + "test-falsey-value-string": "", + "test-falsey-value-number": 0, + }; + const dataUpdate = { + "test-falsey-value-bool": true, + "test-falsey-value-string": "non-empty-string", + "test-falsey-value-number": 10, + }; + + // Compute the expected changes. + const onSetInitial = { + "test-falsey-value-bool": { newValue: false }, + "test-falsey-value-string": { newValue: "" }, + "test-falsey-value-number": { newValue: 0 }, + }; + const onRemovedFalsey = { + "test-falsey-value-bool": { oldValue: false }, + "test-falsey-value-string": { oldValue: "" }, + "test-falsey-value-number": { oldValue: 0 }, + }; + const onUpdatedFalsey = { + "test-falsey-value-bool": { newValue: true, oldValue: false }, + "test-falsey-value-string": { + newValue: "non-empty-string", + oldValue: "", + }, + "test-falsey-value-number": { newValue: 10, oldValue: 0 }, + }; + const keys = Object.keys(dataInitial); + + // Test on removing falsey values. + await storage.set(dataInitial); + await checkChanges(areaName, onSetInitial, "set falsey values"); + await storage.remove(keys); + await checkChanges(areaName, onRemovedFalsey, "remove falsey value"); + + // Test on updating falsey values. + await storage.set(dataInitial); + await checkChanges(areaName, onSetInitial, "set falsey values"); + await storage.set(dataUpdate); + await checkChanges(areaName, onUpdatedFalsey, "set non-falsey values"); + + // Clear the storage state. + await testNonExistingKeys(storage, `${areaName} before clearing`); + await storage.clear(); + await testNonExistingKeys(storage, `${areaName} after clearing`); + await globalChanges; + clearGlobalChanges(); + } + + function CustomObj() { + this.testKey1 = "testValue1"; + } + + CustomObj.prototype.toString = function() { + return '{"testKey2":"testValue2"}'; + }; + + CustomObj.prototype.toJSON = function customObjToJSON() { + return { testKey1: "testValue3" }; + }; + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + await checkChanges( + areaName, + { + "test-prop1": { newValue: "value1" }, + "test-prop2": { newValue: "value2" }, + }, + "set (a)" + ); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + other: "default", + }); + browser.test.assertEq( + "value1", + data["test-prop1"], + "prop1 correct (a)" + ); + browser.test.assertEq( + "value2", + data["test-prop2"], + "prop2 correct (a)" + ); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq( + "value1", + data["test-prop1"], + "prop1 correct (b)" + ); + browser.test.assertEq( + "value2", + data["test-prop2"], + "prop2 correct (b)" + ); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges( + areaName, + { "test-prop1": { oldValue: "value1" } }, + "remove string" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove string)" + ); + browser.test.assertTrue( + "test-prop2" in data, + "prop2 present (remove string)" + ); + + await storage.set({ "test-prop1": "value1" }); + await checkChanges( + areaName, + { "test-prop1": { newValue: "value1" } }, + "set (c)" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq( + data["test-prop1"], + "value1", + "prop1 correct (c)" + ); + browser.test.assertEq( + data["test-prop2"], + "value2", + "prop2 correct (c)" + ); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "remove array" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove array)" + ); + browser.test.assertFalse( + "test-prop2" in data, + "prop2 absent (remove array)" + ); + + await testFalseyValues(areaName); + + // test storage.clear + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "clear" + ); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + + // Make sure the set() handler landed. + await globalChanges; + + let date = new Date(0); + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + nestedObj: { + testKey: {}, + }, + intKeyObj: { + 4: "testValue1", + 3: "testValue2", + 99: "testValue3", + }, + floatKeyObj: { + 1.4: "testValue1", + 5.5: "testValue2", + }, + customObj: new CustomObj(), + arr: [1, 2], + nestedArr: [1, [2, 3]], + date, + regexp: /regexp/, + }, + }); + + await browser.test.assertRejects( + storage.set({ + window, + }), + /DataCloneError|cyclic object value/ + ); + + await browser.test.assertRejects( + storage.set({ "test-prop2": function func() {} }), + /DataCloneError/ + ); + + const recentChanges = await globalChanges; + + browser.test.assertEq( + "value1", + recentChanges["test-prop1"].oldValue, + "oldValue correct" + ); + browser.test.assertEq( + "object", + typeof recentChanges["test-prop1"].newValue, + "newValue is obj" + ); + clearGlobalChanges(); + + data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + }); + let obj = data["test-prop1"]; + + browser.test.assertEq( + "object", + typeof obj.customObj, + "custom object part correct" + ); + browser.test.assertEq( + 1, + Object.keys(obj.customObj).length, + "customObj keys correct" + ); + + if (areaName === "local") { + browser.test.assertEq( + String(date), + String(obj.date), + "date part correct" + ); + browser.test.assertEq( + "/regexp/", + obj.regexp.toString(), + "regexp part correct" + ); + // storage.local doesn't call toJSON + browser.test.assertEq( + "testValue1", + obj.customObj.testKey1, + "customObj keys correct" + ); + } else { + browser.test.assertEq( + "1970-01-01T00:00:00.000Z", + String(obj.date), + "date part correct" + ); + + browser.test.assertEq( + "object", + typeof obj.regexp, + "regexp part is an object" + ); + browser.test.assertEq( + 0, + Object.keys(obj.regexp).length, + "regexp part is an empty object" + ); + // storage.sync does call toJSON + browser.test.assertEq( + "testValue3", + obj.customObj.testKey1, + "customObj keys correct" + ); + } + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("object", typeof obj.obj, "object part correct"); + browser.test.assertEq( + "object", + typeof obj.nestedObj, + "nested object part correct" + ); + browser.test.assertEq( + "object", + typeof obj.nestedObj.testKey, + "nestedObj.testKey part correct" + ); + browser.test.assertEq( + "object", + typeof obj.intKeyObj, + "int key object part correct" + ); + browser.test.assertEq( + "testValue1", + obj.intKeyObj[4], + "intKeyObj[4] part correct" + ); + browser.test.assertEq( + "testValue2", + obj.intKeyObj[3], + "intKeyObj[3] part correct" + ); + browser.test.assertEq( + "testValue3", + obj.intKeyObj[99], + "intKeyObj[99] part correct" + ); + browser.test.assertEq( + "object", + typeof obj.floatKeyObj, + "float key object part correct" + ); + browser.test.assertEq( + "testValue1", + obj.floatKeyObj[1.4], + "floatKeyObj[1.4] part correct" + ); + browser.test.assertEq( + "testValue2", + obj.floatKeyObj[5.5], + "floatKeyObj[5.5] part correct" + ); + + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + browser.test.assertTrue( + Array.isArray(obj.nestedArr), + "nested array part present" + ); + browser.test.assertEq( + 2, + obj.nestedArr.length, + "nestedArr.length part correct" + ); + browser.test.assertEq(1, obj.nestedArr[0], "nestedArr[0] part correct"); + browser.test.assertTrue( + Array.isArray(obj.nestedArr[1]), + "nestedArr[1] part present" + ); + browser.test.assertEq( + 2, + obj.nestedArr[1].length, + "nestedArr[1].length part correct" + ); + browser.test.assertEq( + 2, + obj.nestedArr[1][0], + "nestedArr[1][0] part correct" + ); + browser.test.assertEq( + 3, + obj.nestedArr[1][1], + "nestedArr[1][1] part correct" + ); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); + } + + let extensionData = { + background: `(${backgroundScript})(${checkGetImpl})`, + manifest: { + permissions: ["storage"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${testAreaName}`); + await extension.awaitMessage("test-finished"); + + await extension.unload(); +} + +function test_storage_sync_requires_real_id() { + async function testFn() { + async function background() { + const EXCEPTION_MESSAGE = + "The storage API is not available with a temporary addon ID. " + + "Please add an explicit addon ID to your manifest. " + + "For more information see https://mzl.la/3lPk1aE."; + + await browser.test.assertRejects( + browser.storage.sync.set({ foo: "bar" }), + EXCEPTION_MESSAGE + ); + + browser.test.notifyPass("exception correct"); + } + + let extensionData = { + background, + manifest: { + permissions: ["storage"], + }, + useAddonManager: "temporary", + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("exception correct"); + + await extension.unload(); + } + + return runWithPrefs([[STORAGE_SYNC_PREF, true]], testFn); +} + +// Test for storage areas which don't support getBytesInUse() nor QUOTA +// constants. +async function check_storage_area_no_bytes_in_use(area) { + let impl = browser.storage[area]; + + browser.test.assertEq( + typeof impl.getBytesInUse, + "undefined", + "getBytesInUse API method should not be available" + ); + browser.test.sendMessage("test-complete"); +} + +async function test_background_storage_area_no_bytes_in_use(area) { + const EXT_ID = "test-gbiu@mozilla.org"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXT_ID } }, + }, + background: `(${check_storage_area_no_bytes_in_use})("${area}")`, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await extension.awaitMessage("test-complete"); + await extension.unload(); +} + +async function test_contentscript_storage_area_no_bytes_in_use(area) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + function contentScript(checkImpl) { + browser.test.onMessage.addListener(msg => { + if (msg === "test-local") { + checkImpl("local"); + } else if (msg === "test-sync") { + checkImpl("sync"); + } else { + browser.test.fail(`Unexpected test message received: ${msg}`); + browser.test.sendMessage("test-complete"); + } + }); + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${contentScript})(${check_storage_area_no_bytes_in_use})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${area}`); + await extension.awaitMessage("test-complete"); + + await extension.unload(); + await contentPage.close(); +} + +// Test for storage areas which do support getBytesInUse() (but which may or may +// not support enforcement of the quota) +async function check_storage_area_with_bytes_in_use(area, expectQuota) { + let impl = browser.storage[area]; + + // QUOTA_* constants aren't currently exposed - see bug 1396810. + // However, the quotas are still enforced, so test them here. + // (Note that an implication of this is that we can't test area other than + // 'sync', because its limits are different - so for completeness...) + browser.test.assertEq( + area, + "sync", + "Running test on storage.sync API as expected" + ); + const QUOTA_BYTES_PER_ITEM = 8192; + const MAX_ITEMS = 512; + + // bytes is counted as "length of key as a string, length of value as + // JSON" - ie, quotes not counted in the key, but are in the value. + let value = "x".repeat(QUOTA_BYTES_PER_ITEM - 3); + + await impl.set({ x: value }); // Shouldn't reject on either kinto or rust-based storage.sync. + browser.test.assertEq(await impl.getBytesInUse(null), QUOTA_BYTES_PER_ITEM); + // kinto does implement getBytesInUse() but doesn't enforce a quota. + if (expectQuota) { + await browser.test.assertRejects( + impl.set({ x: value + "x" }), + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + // MAX_ITEMS + await impl.clear(); + let ob = {}; + for (let i = 0; i < MAX_ITEMS; i++) { + ob[`key-${i}`] = "x"; + } + await impl.set(ob); // should work. + await browser.test.assertRejects( + impl.set({ straw: "camel's back" }), // exceeds MAX_ITEMS + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + // QUOTA_BYTES is being already tested for the underlying StorageSyncService + // so we don't duplicate those tests here. + } else { + // Exceeding quota should work on the previous kinto-based storage.sync implementation + await impl.set({ x: value + "x" }); // exceeds quota but should work. + browser.test.assertEq( + await impl.getBytesInUse(null), + QUOTA_BYTES_PER_ITEM + 1, + "Got the expected result from getBytesInUse" + ); + } + browser.test.sendMessage("test-complete"); +} + +async function test_background_storage_area_with_bytes_in_use( + area, + expectQuota +) { + const EXT_ID = "test-gbiu@mozilla.org"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXT_ID } }, + }, + background: `(${check_storage_area_with_bytes_in_use})("${area}", ${expectQuota})`, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await extension.awaitMessage("test-complete"); + await extension.unload(); +} + +async function test_contentscript_storage_area_with_bytes_in_use( + area, + expectQuota +) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + function contentScript(checkImpl) { + browser.test.onMessage.addListener(([area, expectQuota]) => { + if ( + !["local", "sync"].includes(area) || + typeof expectQuota !== "boolean" + ) { + browser.test.fail(`Unexpected test message: [${area}, ${expectQuota}]`); + // Let the test to fail immediately instead of wait for a timeout failure. + browser.test.sendMessage("test-complete"); + return; + } + checkImpl(area, expectQuota); + }); + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${contentScript})(${check_storage_area_with_bytes_in_use})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage([area, expectQuota]); + await extension.awaitMessage("test-complete"); + + await extension.unload(); + await contentPage.close(); +} + +// A couple of common tests for checking content scripts. +async function testStorageContentScript(checkGet) { + let globalChanges, gResolve; + function clearGlobalChanges() { + globalChanges = new Promise(resolve => { + gResolve = resolve; + }); + } + clearGlobalChanges(); + let expectedAreaName; + + browser.storage.onChanged.addListener((changes, areaName) => { + browser.test.assertEq( + expectedAreaName, + areaName, + "Expected area name received by listener" + ); + gResolve(changes); + }); + + async function checkChanges(areaName, changes, message) { + function checkSub(obj1, obj2) { + for (let prop in obj1) { + browser.test.assertTrue( + obj1[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertTrue( + obj2[prop] !== undefined, + `checkChanges ${areaName} ${prop} is missing (${message})` + ); + browser.test.assertEq( + obj1[prop].oldValue, + obj2[prop].oldValue, + `checkChanges ${areaName} ${prop} old (${message})` + ); + browser.test.assertEq( + obj1[prop].newValue, + obj2[prop].newValue, + `checkChanges ${areaName} ${prop} new (${message})` + ); + } + } + + const recentChanges = await globalChanges; + checkSub(changes, recentChanges); + checkSub(recentChanges, changes); + clearGlobalChanges(); + } + + /* eslint-disable dot-notation */ + async function runTests(areaName) { + expectedAreaName = areaName; + let storage = browser.storage[areaName]; + // Set some data and then test getters. + try { + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + await checkChanges( + areaName, + { + "test-prop1": { newValue: "value1" }, + "test-prop2": { newValue: "value2" }, + }, + "set (a)" + ); + + await checkGet(areaName, "test-prop1", "value1"); + await checkGet(areaName, "test-prop2", "value2"); + + let data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + other: "default", + }); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)"); + browser.test.assertEq("default", data["other"], "other correct"); + + data = await storage.get(["test-prop1", "test-prop2", "other"]); + browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)"); + browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)"); + browser.test.assertFalse("other" in data, "other correct"); + + // Remove data in various ways. + await storage.remove("test-prop1"); + await checkChanges( + areaName, + { "test-prop1": { oldValue: "value1" } }, + "remove string" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove string)" + ); + browser.test.assertTrue( + "test-prop2" in data, + "prop2 present (remove string)" + ); + + await storage.set({ "test-prop1": "value1" }); + await checkChanges( + areaName, + { "test-prop1": { newValue: "value1" } }, + "set (c)" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)"); + browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)"); + + await storage.remove(["test-prop1", "test-prop2"]); + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "remove array" + ); + + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse( + "test-prop1" in data, + "prop1 absent (remove array)" + ); + browser.test.assertFalse( + "test-prop2" in data, + "prop2 absent (remove array)" + ); + + // test storage.clear + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + // Make sure that set() handler happened before we clear the + // promise again. + await globalChanges; + + clearGlobalChanges(); + await storage.clear(); + + await checkChanges( + areaName, + { + "test-prop1": { oldValue: "value1" }, + "test-prop2": { oldValue: "value2" }, + }, + "clear" + ); + data = await storage.get(["test-prop1", "test-prop2"]); + browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)"); + browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)"); + + // Make sure we can store complex JSON data. + // known previous values + await storage.set({ "test-prop1": "value1", "test-prop2": "value2" }); + + // Make sure the set() handler landed. + await globalChanges; + + let date = new Date(0); + + clearGlobalChanges(); + await storage.set({ + "test-prop1": { + str: "hello", + bool: true, + null: null, + undef: undefined, + obj: {}, + arr: [1, 2], + date: new Date(0), + regexp: /regexp/, + }, + }); + + await browser.test.assertRejects( + storage.set({ + window, + }), + /DataCloneError|cyclic object value/ + ); + + await browser.test.assertRejects( + storage.set({ "test-prop2": function func() {} }), + /DataCloneError/ + ); + + const recentChanges = await globalChanges; + + browser.test.assertEq( + "value1", + recentChanges["test-prop1"].oldValue, + "oldValue correct" + ); + browser.test.assertEq( + "object", + typeof recentChanges["test-prop1"].newValue, + "newValue is obj" + ); + clearGlobalChanges(); + + data = await storage.get({ + "test-prop1": undefined, + "test-prop2": undefined, + }); + let obj = data["test-prop1"]; + + if (areaName === "local") { + browser.test.assertEq( + String(date), + String(obj.date), + "date part correct" + ); + browser.test.assertEq( + "/regexp/", + obj.regexp.toString(), + "regexp part correct" + ); + } else { + browser.test.assertEq( + "1970-01-01T00:00:00.000Z", + String(obj.date), + "date part correct" + ); + + browser.test.assertEq( + "object", + typeof obj.regexp, + "regexp part is an object" + ); + browser.test.assertEq( + 0, + Object.keys(obj.regexp).length, + "regexp part is an empty object" + ); + } + + browser.test.assertEq("hello", obj.str, "string part correct"); + browser.test.assertEq(true, obj.bool, "bool part correct"); + browser.test.assertEq(null, obj.null, "null part correct"); + browser.test.assertEq(undefined, obj.undef, "undefined part correct"); + browser.test.assertEq(undefined, obj.window, "window part correct"); + browser.test.assertEq("object", typeof obj.obj, "object part correct"); + browser.test.assertTrue(Array.isArray(obj.arr), "array part present"); + browser.test.assertEq(1, obj.arr[0], "arr[0] part correct"); + browser.test.assertEq(2, obj.arr[1], "arr[1] part correct"); + browser.test.assertEq(2, obj.arr.length, "arr.length part correct"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage"); + } + } + + browser.test.onMessage.addListener(msg => { + let promise; + if (msg === "test-local") { + promise = runTests("local"); + } else if (msg === "test-sync") { + promise = runTests("sync"); + } + promise.then(() => browser.test.sendMessage("test-finished")); + }); + + browser.test.sendMessage("ready"); +} + +async function test_contentscript_storage(storageType) { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + + permissions: ["storage"], + }, + + files: { + "content_script.js": `(${testStorageContentScript})(${checkGetImpl})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage(`test-${storageType}`); + await extension.awaitMessage("test-finished"); + + await extension.unload(); + await contentPage.close(); +} diff --git a/toolkit/components/extensions/test/xpcshell/head_sync.js b/toolkit/components/extensions/test/xpcshell/head_sync.js new file mode 100644 index 0000000000..691743c696 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_sync.js @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +/* exported withSyncContext */ + +ChromeUtils.import("resource://gre/modules/Services.jsm", this); +ChromeUtils.import("resource://gre/modules/ExtensionCommon.jsm", this); + +class KintoExtContext extends ExtensionCommon.BaseContext { + constructor(principal) { + super(); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, { wantXrays: false }); + this.extension = { id: "test@web.extension" }; + } + + get cloneScope() { + return this.sandbox; + } +} + +/** + * Call the given function with a newly-constructed context. + * Unload the context on the way out. + * + * @param {function} f the function to call + */ +async function withContext(f) { + const ssm = Services.scriptSecurityManager; + const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin( + "http://www.example.org" + ); + const context = new KintoExtContext(PRINCIPAL1); + try { + await f(context); + } finally { + await context.unload(); + } +} + +/** + * Like withContext(), but also turn on the "storage.sync" pref for + * the duration of the function. + * Calls to this function can be replaced with calls to withContext + * once the pref becomes on by default. + * + * @param {function} f the function to call + */ +async function withSyncContext(f) { + const STORAGE_SYNC_PREF = "webextensions.storage.sync.enabled"; + let prefs = Services.prefs; + + try { + prefs.setBoolPref(STORAGE_SYNC_PREF, true); + await withContext(f); + } finally { + prefs.clearUserPref(STORAGE_SYNC_PREF); + } +} diff --git a/toolkit/components/extensions/test/xpcshell/head_telemetry.js b/toolkit/components/extensions/test/xpcshell/head_telemetry.js new file mode 100644 index 0000000000..6492d8f995 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/head_telemetry.js @@ -0,0 +1,110 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported IS_OOP, valueSum, clearHistograms, getSnapshots, promiseTelemetryRecorded */ + +ChromeUtils.defineModuleGetter( + this, + "ContentTaskUtils", + "resource://testing-common/ContentTaskUtils.jsm" +); + +const IS_OOP = Services.prefs.getBoolPref("extensions.webextensions.remote"); + +function valueSum(arr) { + return Object.values(arr).reduce((a, b) => a + b, 0); +} + +function clearHistograms() { + Services.telemetry.getSnapshotForHistograms("main", true /* clear */); + Services.telemetry.getSnapshotForKeyedHistograms("main", true /* clear */); +} + +function getSnapshots(process) { + return Services.telemetry.getSnapshotForHistograms("main", false /* clear */)[ + process + ]; +} + +function getKeyedSnapshots(process) { + return Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false /* clear */ + )[process]; +} + +// TODO Bug 1357509: There is no good way to make sure that the parent received +// the histogram entries from the extension and content processes. Let's stick +// to the ugly, spinning the event loop until we have a good approach. +function promiseTelemetryRecorded(id, process, expectedCount) { + let condition = () => { + let snapshot = Services.telemetry.getSnapshotForHistograms( + "main", + false /* clear */ + )[process][id]; + return snapshot && valueSum(snapshot.values) >= expectedCount; + }; + return ContentTaskUtils.waitForCondition(condition); +} + +function promiseKeyedTelemetryRecorded( + id, + process, + expectedKey, + expectedCount +) { + let condition = () => { + let snapshot = Services.telemetry.getSnapshotForKeyedHistograms( + "main", + false /* clear */ + )[process][id]; + return ( + snapshot && + snapshot[expectedKey] && + valueSum(snapshot[expectedKey].values) >= expectedCount + ); + }; + return ContentTaskUtils.waitForCondition(condition); +} + +function assertHistogramSnapshot( + histogramId, + { keyed, processSnapshot, expectedValue }, + msg +) { + let histogram; + + if (keyed) { + histogram = Services.telemetry.getKeyedHistogramById(histogramId); + } else { + histogram = Services.telemetry.getHistogramById(histogramId); + } + + let res = processSnapshot(histogram.snapshot()); + Assert.deepEqual(res, expectedValue, msg); + return res; +} + +function assertHistogramEmpty(histogramId) { + assertHistogramSnapshot( + histogramId, + { + processSnapshot: snapshot => snapshot.sum, + expectedValue: 0, + }, + `No data recorded for histogram: ${histogramId}.` + ); +} + +function assertKeyedHistogramEmpty(histogramId) { + assertHistogramSnapshot( + histogramId, + { + keyed: true, + processSnapshot: snapshot => Object.keys(snapshot).length, + expectedValue: 0, + }, + `No data recorded for histogram: ${histogramId}.` + ); +} diff --git a/toolkit/components/extensions/test/xpcshell/native_messaging.ini b/toolkit/components/extensions/test/xpcshell/native_messaging.ini new file mode 100644 index 0000000000..b64cda83c8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/native_messaging.ini @@ -0,0 +1,15 @@ +[DEFAULT] +head = head.js head_e10s.js head_native_messaging.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +subprocess = true +support-files = + data/** +tags = webextensions + +[test_ext_native_messaging.js] +skip-if = (os == "win" && processor == "aarch64") # bug 1530841 +[test_ext_native_messaging_perf.js] +skip-if = tsan # Unreasonably slow, bug 1612707 +[test_ext_native_messaging_unresponsive.js] diff --git a/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js new file mode 100644 index 0000000000..ad763cb321 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ExtensionStorageSync_migration_kinto.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Import the rust-based and kinto-based implementations +const { extensionStorageSync: rustImpl } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSync.jsm" +); +const { extensionStorageSync: kintoImpl } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSyncKinto.jsm" +); + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +add_task(async function test_sync_migration() { + // There's no good reason to perform this test via test extensions - we just + // call the underlying APIs directly. + + // Set some stuff using the kinto-based impl. + let e1 = { id: "test@mozilla.com" }; + let c1 = { extension: e1, callOnClose() {} }; + await kintoImpl.set(e1, { foo: "bar" }, c1); + + let e2 = { id: "test-2@mozilla.com" }; + let c2 = { extension: e2, callOnClose() {} }; + await kintoImpl.set(e2, { second: "2nd" }, c2); + + let e3 = { id: "test-3@mozilla.com" }; + let c3 = { extension: e3, callOnClose() {} }; + + // And all the data should be magically migrated. + Assert.deepEqual(await rustImpl.get(e1, "foo", c1), { foo: "bar" }); + Assert.deepEqual(await rustImpl.get(e2, null, c2), { second: "2nd" }); + + // Sanity check we really are doing what we think we are - set a value in our + // new one, it should not be reflected by kinto. + await rustImpl.set(e3, { third: "3rd" }, c3); + Assert.deepEqual(await rustImpl.get(e3, null, c3), { third: "3rd" }); + Assert.deepEqual(await kintoImpl.get(e3, null, c3), {}); + // cleanup. + await kintoImpl.clear(e1, c1); + await kintoImpl.clear(e2, c2); + await kintoImpl.clear(e3, c3); + await rustImpl.clear(e1, c1); + await rustImpl.clear(e2, c2); + await rustImpl.clear(e3, c3); +}); + +// It would be great to have failure tests, but that seems impossible to have +// in automated tests given the conditions under which we migrate - it would +// basically require us to arrange for zero free disk space or to somehow +// arrange for sqlite to see an io error. Specially crafted "corrupt" +// sqlite files doesn't help because that file must not exist for us to even +// attempt migration. +// +// But - what we can test is that if .migratedOk on the new impl ever goes to +// false we delegate correctly. +add_task(async function test_sync_migration_delgates() { + let e1 = { id: "test@mozilla.com" }; + let c1 = { extension: e1, callOnClose() {} }; + await kintoImpl.set(e1, { foo: "bar" }, c1); + + // We think migration went OK - `get` shouldn't see kinto. + Assert.deepEqual(rustImpl.get(e1, null, c1), {}); + + info( + "Setting migration failure flag to ensure we delegate to kinto implementation" + ); + rustImpl.migrationOk = false; + // get should now be seeing kinto. + Assert.deepEqual(await rustImpl.get(e1, null, c1), { foo: "bar" }); + // check everything else delegates. + + await rustImpl.set(e1, { foo: "foo" }, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" }); + + Assert.equal(await rustImpl.getBytesInUse(e1, null, c1), 8); + + await rustImpl.remove(e1, "foo", c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), {}); + + await rustImpl.set(e1, { foo: "foo" }, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), { foo: "foo" }); + await rustImpl.clear(e1, c1); + Assert.deepEqual(await kintoImpl.get(e1, null, c1), {}); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js new file mode 100644 index 0000000000..c0aa4254ad --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_MatchPattern.js @@ -0,0 +1,552 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_MatchPattern_matches() { + function test(url, pattern, normalized = pattern, options = {}, explicit) { + let uri = Services.io.newURI(url); + + pattern = Array.prototype.concat.call(pattern); + normalized = Array.prototype.concat.call(normalized); + + let patterns = pattern.map(pat => new MatchPattern(pat, options)); + + let set = new MatchPatternSet(pattern, options); + let set2 = new MatchPatternSet(patterns, options); + + deepEqual( + set2.patterns, + patterns, + "Patterns in set should equal the input patterns" + ); + + equal( + set.matches(uri, explicit), + set2.matches(uri, explicit), + "Single pattern and pattern set should return the same match" + ); + + for (let [i, pat] of patterns.entries()) { + equal( + pat.pattern, + normalized[i], + "Pattern property should contain correct normalized pattern value" + ); + } + + if (patterns.length == 1) { + equal( + patterns[0].matches(uri, explicit), + set.matches(uri, explicit), + "Single pattern and string set should return the same match" + ); + } + + return set.matches(uri, explicit); + } + + function pass({ url, pattern, normalized, options, explicit }) { + ok( + test(url, pattern, normalized, options, explicit), + `Expected match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function fail({ url, pattern, normalized, options, explicit }) { + ok( + !test(url, pattern, normalized, options, explicit), + `Expected no match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function invalid({ pattern }) { + Assert.throws( + () => new MatchPattern(pattern), + /.*/, + `Invalid pattern '${pattern}' should throw` + ); + Assert.throws( + () => new MatchPatternSet([pattern]), + /.*/, + `Invalid pattern '${pattern}' should throw` + ); + } + + // Invalid pattern. + invalid({ pattern: "" }); + + // Pattern must include trailing slash. + invalid({ pattern: "http://mozilla.org" }); + + // Protocol not allowed. + invalid({ pattern: "gopher://wuarchive.wustl.edu/" }); + + pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/" }); + pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/" }); + + pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/" }); + pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/" }); + fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/" }); + fail({ url: "ftp://mozilla.org/", pattern: "*://mozilla.org/" }); + + fail({ url: "http://mozilla.com", pattern: "http://*mozilla.com*/" }); + fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/" }); + invalid({ pattern: "http:/mozilla.com/" }); + + pass({ url: "http://google.com", pattern: "http://*.google.com/" }); + pass({ url: "http://docs.google.com", pattern: "http://*.google.com/" }); + + pass({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org/" }); + pass({ url: "http://mozilla.org:8080", pattern: "*://mozilla.org/" }); + fail({ url: "http://mozilla.org:8080", pattern: "http://mozilla.org:8080/" }); + + // Now try with * in the path. + pass({ url: "http://mozilla.org", pattern: "http://mozilla.org/*" }); + pass({ url: "http://mozilla.org/", pattern: "http://mozilla.org/*" }); + + pass({ url: "http://mozilla.org/", pattern: "*://mozilla.org/*" }); + pass({ url: "https://mozilla.org/", pattern: "*://mozilla.org/*" }); + fail({ url: "file://mozilla.org/", pattern: "*://mozilla.org/*" }); + fail({ url: "http://mozilla.com", pattern: "http://mozilla.*/*" }); + + pass({ url: "http://google.com", pattern: "http://*.google.com/*" }); + pass({ url: "http://docs.google.com", pattern: "http://*.google.com/*" }); + + // Check path stuff. + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/" }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*" }); + pass({ + url: "http://mozilla.com/abc/def", + pattern: "http://mozilla.com/a*f", + }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/a*" }); + pass({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*f" }); + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*e" }); + fail({ url: "http://mozilla.com/abc/def", pattern: "http://mozilla.com/*c" }); + + invalid({ pattern: "http:///a.html" }); + pass({ url: "file:///foo", pattern: "file:///foo*" }); + pass({ url: "file:///foo/bar.html", pattern: "file:///foo*" }); + + pass({ url: "http://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "https://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "ftp://mozilla.org/a", pattern: "<all_urls>" }); + pass({ url: "file:///a", pattern: "<all_urls>" }); + fail({ url: "gopher://wuarchive.wustl.edu/a", pattern: "<all_urls>" }); + + // Multiple patterns. + pass({ url: "http://mozilla.org", pattern: ["http://mozilla.org/"] }); + pass({ + url: "http://mozilla.org", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + pass({ + url: "http://mozilla.com", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + fail({ + url: "http://mozilla.biz", + pattern: ["http://mozilla.org/", "http://mozilla.com/"], + }); + + // Match url with fragments. + pass({ + url: "http://mozilla.org/base#some-fragment", + pattern: "http://mozilla.org/base", + }); + + // Match data:-URLs. + pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,foo"] }); + pass({ url: "data:text/plain,foo", pattern: ["data:text/plain,*"] }); + pass({ + url: "data:text/plain;charset=utf-8,foo", + pattern: ["data:text/plain;charset=utf-8,foo"], + }); + fail({ + url: "data:text/plain,foo", + pattern: ["data:text/plain;charset=utf-8,foo"], + }); + fail({ + url: "data:text/plain;charset=utf-8,foo", + pattern: ["data:text/plain,foo"], + }); + + // Privileged matchers: + invalid({ pattern: "about:foo" }); + invalid({ pattern: "resource://foo/*" }); + + pass({ + url: "about:foo", + pattern: ["about:foo", "about:foo*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "about:foo", + pattern: ["about:foo*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "about:foobar", + pattern: ["about:foo*"], + options: { restrictSchemes: false }, + }); + + pass({ + url: "resource://foo/bar", + pattern: ["resource://foo/bar"], + options: { restrictSchemes: false }, + }); + fail({ + url: "resource://fog/bar", + pattern: ["resource://foo/bar"], + options: { restrictSchemes: false }, + }); + fail({ + url: "about:foo", + pattern: ["about:meh"], + options: { restrictSchemes: false }, + }); + + // Matchers for schemes without host should ignore ignorePath. + pass({ + url: "about:reader?http://e.com/", + pattern: ["about:reader*"], + options: { ignorePath: true, restrictSchemes: false }, + }); + pass({ url: "data:,", pattern: ["data:,*"], options: { ignorePath: true } }); + + // Matchers for schems without host should still match even if the explicit (host) flag is set. + pass({ + url: "about:reader?explicit", + pattern: ["about:reader*"], + options: { restrictSchemes: false }, + explicit: true, + }); + pass({ + url: "about:reader?explicit", + pattern: ["about:reader?explicit"], + options: { restrictSchemes: false }, + explicit: true, + }); + pass({ url: "data:,explicit", pattern: ["data:,explicit"], explicit: true }); + pass({ url: "data:,explicit", pattern: ["data:,*"], explicit: true }); + + // Matchers without "//" separator in the pattern. + pass({ url: "data:text/plain;charset=utf-8,foo", pattern: ["data:*"] }); + pass({ + url: "about:blank", + pattern: ["about:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "view-source:https://example.com", + pattern: ["view-source:*"], + options: { restrictSchemes: false }, + }); + invalid({ pattern: ["chrome:*"], options: { restrictSchemes: false } }); + invalid({ pattern: "http:*" }); + + // Matchers for unrecognized schemes. + invalid({ pattern: "unknown-scheme:*" }); + pass({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:foo"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme:*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme://foo"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme://*"], + options: { restrictSchemes: false }, + }); + pass({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme:*"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme://foo", + pattern: ["unknown-scheme:foo"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme://foo"], + options: { restrictSchemes: false }, + }); + fail({ + url: "unknown-scheme:foo", + pattern: ["unknown-scheme://*"], + options: { restrictSchemes: false }, + }); + + // Matchers for IPv6 + pass({ url: "http://[::1]/", pattern: ["http://[::1]/"] }); + pass({ + url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/", + pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"], + }); + fail({ + url: "http://[2:4:6:3:2:3:f:b]/", + pattern: ["http://[2a03:4000:6:310e:216:3eff:fe53:99b]/"], + }); + + // Before fixing Bug 1529230, the only way to match a specific IPv6 url is by droping the brackets in pattern, + // thus we keep this pattern valid for the sake of backward compatibility + pass({ url: "http://[::1]/", pattern: ["http://::1/"] }); + pass({ + url: "http://[2a03:4000:6:310e:216:3eff:fe53:99b]/", + pattern: ["http://2a03:4000:6:310e:216:3eff:fe53:99b/"], + }); +}); + +add_task(async function test_MatchPattern_overlaps() { + function test(filter, hosts, optional) { + filter = Array.prototype.concat.call(filter); + hosts = Array.prototype.concat.call(hosts); + optional = Array.prototype.concat.call(optional); + + const set = new MatchPatternSet([...hosts, ...optional]); + const pat = new MatchPatternSet(filter); + return set.overlapsAll(pat); + } + + function pass({ filter = [], hosts = [], optional = [] }) { + ok( + test(filter, hosts, optional), + `Expected overlap: ${filter}, ${hosts} (${optional})` + ); + } + + function fail({ filter = [], hosts = [], optional = [] }) { + ok( + !test(filter, hosts, optional), + `Expected no overlap: ${filter}, ${hosts} (${optional})` + ); + } + + // Direct comparison. + pass({ hosts: "http://ab.cd/", filter: "http://ab.cd/" }); + fail({ hosts: "http://ab.cd/", filter: "ftp://ab.cd/" }); + + // Wildcard protocol. + pass({ hosts: "*://ab.cd/", filter: "https://ab.cd/" }); + fail({ hosts: "*://ab.cd/", filter: "ftp://ab.cd/" }); + + // Wildcard subdomain. + pass({ hosts: "http://*.ab.cd/", filter: "http://ab.cd/" }); + pass({ hosts: "http://*.ab.cd/", filter: "http://www.ab.cd/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://ab.cd.ef/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://www.cd/" }); + + // Wildcard subsumed. + pass({ hosts: "http://*.ab.cd/", filter: "http://*.cd/" }); + fail({ hosts: "http://*.cd/", filter: "http://*.xy/" }); + + // Subdomain vs substring. + fail({ hosts: "http://*.ab.cd/", filter: "http://fake-ab.cd/" }); + fail({ hosts: "http://*.ab.cd/", filter: "http://*.fake-ab.cd/" }); + + // Wildcard domain. + pass({ hosts: "http://*/", filter: "http://ab.cd/" }); + fail({ hosts: "http://*/", filter: "https://ab.cd/" }); + + // Wildcard wildcards. + pass({ hosts: "<all_urls>", filter: "ftp://ab.cd/" }); + fail({ hosts: "<all_urls>" }); + + // Multiple hosts. + pass({ hosts: ["http://ab.cd/"], filter: ["http://ab.cd/"] }); + pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.cd/" }); + pass({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.xy/" }); + fail({ hosts: ["http://ab.cd/", "http://ab.xy/"], filter: "http://ab.zz/" }); + + // Multiple Multiples. + pass({ + hosts: ["http://*.ab.cd/"], + filter: ["http://ab.cd/", "http://www.ab.cd/"], + }); + pass({ + hosts: ["http://ab.cd/", "http://ab.xy/"], + filter: ["http://ab.cd/", "http://ab.xy/"], + }); + fail({ + hosts: ["http://ab.cd/", "http://ab.xy/"], + filter: ["http://ab.cd/", "http://ab.zz/"], + }); + + // Optional. + pass({ hosts: [], optional: "http://ab.cd/", filter: "http://ab.cd/" }); + pass({ + hosts: "http://ab.cd/", + optional: "http://ab.xy/", + filter: ["http://ab.cd/", "http://ab.xy/"], + }); + fail({ + hosts: "http://ab.cd/", + optional: "https://ab.xy/", + filter: "http://ab.xy/", + }); +}); + +add_task(async function test_MatchGlob() { + function test(url, pattern) { + let m = new MatchGlob(pattern[0]); + return m.matches(Services.io.newURI(url).spec); + } + + function pass({ url, pattern }) { + ok( + test(url, pattern), + `Expected match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + function fail({ url, pattern }) { + ok( + !test(url, pattern), + `Expected no match: ${JSON.stringify(pattern)}, ${url}` + ); + } + + let moz = "http://mozilla.org"; + + pass({ url: moz, pattern: ["*"] }); + pass({ url: moz, pattern: ["http://*"] }); + pass({ url: moz, pattern: ["*mozilla*"] }); + // pass({url: moz, pattern: ["*example*", "*mozilla*"]}); + + pass({ url: moz, pattern: ["*://*"] }); + pass({ url: "https://mozilla.org", pattern: ["*://*"] }); + + // Documentation example + pass({ + url: "http://www.example.com/foo/bar", + pattern: ["http://???.example.com/foo/*"], + }); + pass({ + url: "http://the.example.com/foo/", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://my.example.com/foo/bar", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://example.com/foo/", + pattern: ["http://???.example.com/foo/*"], + }); + fail({ + url: "http://www.example.com/foo", + pattern: ["http://???.example.com/foo/*"], + }); + + // Matches path + let path = moz + "/abc/def"; + pass({ url: path, pattern: ["*def"] }); + pass({ url: path, pattern: ["*c/d*"] }); + pass({ url: path, pattern: ["*org/abc*"] }); + fail({ url: path + "/", pattern: ["*def"] }); + + // Trailing slash + pass({ url: moz, pattern: ["*.org/"] }); + fail({ url: moz, pattern: ["*.org"] }); + + // Wrong TLD + fail({ url: moz, pattern: ["*oz*.com/"] }); + // Case sensitive + fail({ url: moz, pattern: ["*.ORG/"] }); +}); + +add_task(async function test_MatchPattern_subsumes() { + function test(oldPat, newPat) { + let m = new MatchPatternSet(oldPat); + return m.subsumes(new MatchPattern(newPat)); + } + + function pass({ oldPat, newPat }) { + ok(test(oldPat, newPat), `${JSON.stringify(oldPat)} subsumes "${newPat}"`); + } + + function fail({ oldPat, newPat }) { + ok( + !test(oldPat, newPat), + `${JSON.stringify(oldPat)} doesn't subsume "${newPat}"` + ); + } + + pass({ oldPat: ["<all_urls>"], newPat: "*://*/*" }); + pass({ oldPat: ["<all_urls>"], newPat: "http://*/*" }); + pass({ oldPat: ["<all_urls>"], newPat: "http://*.example.com/*" }); + + pass({ oldPat: ["*://*/*"], newPat: "http://*/*" }); + pass({ oldPat: ["*://*/*"], newPat: "wss://*/*" }); + pass({ oldPat: ["*://*/*"], newPat: "http://*.example.com/*" }); + + pass({ oldPat: ["*://*.example.com/*"], newPat: "http://*.example.com/*" }); + pass({ oldPat: ["*://*.example.com/*"], newPat: "*://sub.example.com/*" }); + + pass({ oldPat: ["https://*/*"], newPat: "https://*.example.com/*" }); + pass({ + oldPat: ["http://*.example.com/*"], + newPat: "http://subdomain.example.com/*", + }); + pass({ + oldPat: ["http://*.sub.example.com/*"], + newPat: "http://sub.example.com/*", + }); + pass({ + oldPat: ["http://*.sub.example.com/*"], + newPat: "http://sec.sub.example.com/*", + }); + pass({ + oldPat: ["http://www.example.com/*"], + newPat: "http://www.example.com/path/*", + }); + pass({ + oldPat: ["http://www.example.com/path/*"], + newPat: "http://www.example.com/*", + }); + + fail({ oldPat: ["*://*/*"], newPat: "<all_urls>" }); + fail({ oldPat: ["*://*/*"], newPat: "ftp://*/*" }); + fail({ oldPat: ["*://*/*"], newPat: "file://*/*" }); + + fail({ oldPat: ["http://example.com/*"], newPat: "*://example.com/*" }); + fail({ oldPat: ["http://example.com/*"], newPat: "https://example.com/*" }); + fail({ + oldPat: ["http://example.com/*"], + newPat: "http://otherexample.com/*", + }); + fail({ oldPat: ["http://example.com/*"], newPat: "http://*.example.com/*" }); + fail({ + oldPat: ["http://example.com/*"], + newPat: "http://subdomain.example.com/*", + }); + + fail({ + oldPat: ["http://subdomain.example.com/*"], + newPat: "http://example.com/*", + }); + fail({ + oldPat: ["http://subdomain.example.com/*"], + newPat: "http://*.example.com/*", + }); + fail({ + oldPat: ["http://sub.example.com/*"], + newPat: "http://*.sub.example.com/*", + }); + + fail({ oldPat: ["ws://example.com/*"], newPat: "wss://example.com/*" }); + fail({ oldPat: ["http://example.com/*"], newPat: "ws://example.com/*" }); + fail({ oldPat: ["https://example.com/*"], newPat: "wss://example.com/*" }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js new file mode 100644 index 0000000000..8b627a0ee9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_StorageSyncService.js @@ -0,0 +1,286 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const NS_ERROR_DOM_QUOTA_EXCEEDED_ERR = 0x80530016; + +XPCOMUtils.defineLazyServiceGetter( + this, + "StorageSyncService", + "@mozilla.org/extensions/storage/sync;1", + "nsIInterfaceRequestor" +); + +function promisify(func, ...params) { + return new Promise((resolve, reject) => { + let changes = []; + func(...params, { + QueryInterface: ChromeUtils.generateQI([ + "mozIExtensionStorageListener", + "mozIExtensionStorageCallback", + "mozIBridgedSyncEngineCallback", + "mozIBridgedSyncEngineApplyCallback", + ]), + onChanged(extId, json) { + changes.push({ extId, changes: JSON.parse(json) }); + }, + handleSuccess(value) { + resolve({ + changes, + value: typeof value == "string" ? JSON.parse(value) : value, + }); + }, + handleError(code, message) { + reject(Components.Exception(message, code)); + }, + }); + }); +} + +add_task(async function setup_storage_sync() { + // So that we can write to the profile directory. + do_get_profile(); +}); + +add_task(async function test_storage_sync_service() { + const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + { + let { changes, value } = await promisify( + service.set, + "ext-1", + JSON.stringify({ + hi: "hello! 💖", + bye: "adiós", + }) + ); + deepEqual( + changes, + [ + { + extId: "ext-1", + changes: { + hi: { + newValue: "hello! 💖", + }, + bye: { + newValue: "adiós", + }, + }, + }, + ], + "`set` should notify listeners about changes" + ); + ok(!value, "`set` should not return a value"); + } + + { + let { changes, value } = await promisify( + service.get, + "ext-1", + JSON.stringify(["hi"]) + ); + deepEqual(changes, [], "`get` should not notify listeners"); + deepEqual( + value, + { + hi: "hello! 💖", + }, + "`get` with key should return value" + ); + + let { value: allValues } = await promisify(service.get, "ext-1", "null"); + deepEqual( + allValues, + { + hi: "hello! 💖", + bye: "adiós", + }, + "`get` without a key should return all values" + ); + } + + { + await promisify( + service.set, + "ext-2", + JSON.stringify({ + hi: "hola! 👋", + }) + ); + await promisify(service.clear, "ext-1"); + let { value: allValues } = await promisify(service.get, "ext-1", "null"); + deepEqual(allValues, {}, "clear removed ext-1"); + + let { value: allValues2 } = await promisify(service.get, "ext-2", "null"); + deepEqual(allValues2, { hi: "hola! 👋" }, "clear didn't remove ext-2"); + // We need to clear data for ext-2 too, so later tests don't fail due to + // this data. + await promisify(service.clear, "ext-2"); + } +}); + +add_task(async function test_storage_sync_bridged_engine() { + const area = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + + info("Add some local items"); + await promisify(area.set, "ext-1", JSON.stringify({ a: "abc" })); + await promisify(area.set, "ext-2", JSON.stringify({ b: "xyz" })); + + info("Start a sync"); + await promisify(engine.syncStarted); + + info("Store some incoming synced items"); + let incomingEnvelopesAsJSON = [ + { + id: "guidAAA", + modified: 0.1, + cleartext: JSON.stringify({ + id: "guidAAA", + extId: "ext-2", + data: JSON.stringify({ + c: 1234, + }), + }), + }, + { + id: "guidBBB", + modified: 0.1, + cleartext: JSON.stringify({ + id: "guidBBB", + extId: "ext-3", + data: JSON.stringify({ + d: "new! ✨", + }), + }), + }, + ].map(e => JSON.stringify(e)); + await promisify(area.storeIncoming, incomingEnvelopesAsJSON); + + info("Merge"); + // Three levels of JSON wrapping: each outgoing envelope, the cleartext in + // each envelope, and the extension storage data in each cleartext. + let { value: outgoingEnvelopesAsJSON } = await promisify(area.apply); + let outgoingEnvelopes = outgoingEnvelopesAsJSON.map(json => JSON.parse(json)); + let parsedCleartexts = outgoingEnvelopes.map(e => JSON.parse(e.cleartext)); + let parsedData = parsedCleartexts.map(c => JSON.parse(c.data)); + + let { changes } = await promisify( + area.QueryInterface(Ci.mozISyncedExtensionStorageArea) + .fetchPendingSyncChanges + ); + deepEqual( + changes, + [ + { + extId: "ext-2", + changes: { + c: { newValue: 1234 }, + }, + }, + { + extId: "ext-3", + changes: { + d: { newValue: "new! ✨" }, + }, + }, + ], + "Should return pending synced changes for observers" + ); + + // ext-1 doesn't exist remotely yet, so the Rust sync layer will generate + // a GUID for it. We don't know what it is, so we find it by the extension + // ID. + let ext1Index = parsedCleartexts.findIndex(c => c.extId == "ext-1"); + greater(ext1Index, -1, "Should find envelope for ext-1"); + let ext1Guid = outgoingEnvelopes[ext1Index].id; + + // ext-2 has a remote GUID that we set in the test above. + let ext2Index = outgoingEnvelopes.findIndex(c => c.id == "guidAAA"); + greater(ext2Index, -1, "Should find envelope for ext-2"); + + equal(outgoingEnvelopes.length, 2, "Should upload ext-1 and ext-2"); + equal( + ext1Guid, + parsedCleartexts[ext1Index].id, + "ext-1 ID in envelope should match cleartext" + ); + deepEqual( + parsedData[ext1Index], + { + a: "abc", + }, + "Should upload new data for ext-1" + ); + equal( + outgoingEnvelopes[ext2Index].id, + parsedCleartexts[ext2Index].id, + "ext-2 ID in envelope should match cleartext" + ); + deepEqual( + parsedData[ext2Index], + { + b: "xyz", + c: 1234, + }, + "Should merge local and remote data for ext-2" + ); + + info("Mark all extensions as uploaded"); + await promisify(engine.setUploaded, 0, [ext1Guid, "guidAAA"]); + + info("Finish sync"); + await promisify(engine.syncFinished); + + // Try fetching values for the remote-only extension we just synced. + let { value: ext3Value } = await promisify(area.get, "ext-3", "null"); + deepEqual( + ext3Value, + { + d: "new! ✨", + }, + "Should return new keys for ext-3" + ); + + info("Try applying a second time"); + let secondApply = await promisify(area.apply); + deepEqual(secondApply.value, {}, "Shouldn't merge anything on second apply"); + + info("Wipe all items"); + await promisify(engine.wipe); + + for (let extId of ["ext-1", "ext-2", "ext-3"]) { + // `get` always returns an object, even if there are no keys for the + // extension ID. + let { value } = await promisify(area.get, extId, "null"); + deepEqual(value, {}, `Wipe should remove all values for ${extId}`); + } +}); + +add_task(async function test_storage_sync_quota() { + const service = StorageSyncService.getInterface(Ci.mozIExtensionStorageArea); + const engine = StorageSyncService.getInterface(Ci.mozIBridgedSyncEngine); + await promisify(engine.wipe); + await promisify(service.set, "ext-1", JSON.stringify({ x: "hi" })); + await promisify(service.set, "ext-1", JSON.stringify({ longer: "value" })); + + let { value: v1 } = await promisify(service.getBytesInUse, "ext-1", '"x"'); + Assert.equal(v1, 5); // key len without quotes, value len with quotes. + let { value: v2 } = await promisify(service.getBytesInUse, "ext-1", "null"); + // 5 from 'x', plus 'longer' (6 for key, 7 for value = 13) = 18. + Assert.equal(v2, 18); + + // Now set something greater than our quota. + await Assert.rejects( + promisify( + service.set, + "ext-1", + JSON.stringify({ + big: "x".repeat(Ci.mozIExtensionStorageArea.SYNC_QUOTA_BYTES), + }) + ), + ex => ex.result == NS_ERROR_DOM_QUOTA_EXCEEDED_ERR, + "should reject with NS_ERROR_DOM_QUOTA_EXCEEDED_ERR" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js new file mode 100644 index 0000000000..78d61d4b29 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionContentScript.js @@ -0,0 +1,209 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { newURI } = Services.io; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, +}); + +add_task(async function test_WebExtensinonContentScript_url_matching() { + let contentScript = new WebExtensionContentScript(policy, { + matches: new MatchPatternSet(["http://foo.com/bar", "*://bar.com/baz/*"]), + + excludeMatches: new MatchPatternSet(["*://bar.com/baz/quux"]), + + includeGlobs: ["*flerg*", "*.com/bar", "*/quux"].map( + glob => new MatchGlob(glob) + ), + + excludeGlobs: ["*glorg*"].map(glob => new MatchGlob(glob)), + }); + + ok( + contentScript.matchesURI(newURI("http://foo.com/bar")), + "Simple matches include should match" + ); + + ok( + contentScript.matchesURI(newURI("https://bar.com/baz/xflergx")), + "Simple matches include should match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/xx")), + "Failed includeGlobs match pattern should not match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/quux")), + "Excluded match pattern should not match" + ); + + ok( + !contentScript.matchesURI(newURI("https://bar.com/baz/xflergxglorgx")), + "Excluded match glob should not match" + ); +}); + +async function loadURL(url) { + let requests = new Map(); + + function requestObserver(request) { + request.QueryInterface(Ci.nsIChannel); + if (request.isDocument) { + requests.set(request.name, request); + } + } + + Services.obs.addObserver(requestObserver, "http-on-examine-response"); + + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + Services.obs.removeObserver(requestObserver, "http-on-examine-response"); + + return { contentPage, requests }; +} + +add_task(async function test_WebExtensinonContentScript_frame_matching() { + if (AppConstants.platform == "linux") { + // The windowless browser currently does not load correctly on Linux on + // infra. + return; + } + + let baseURL = `http://example.com/data`; + let urls = { + topLevel: `${baseURL}/file_toplevel.html`, + iframe: `${baseURL}/file_iframe.html`, + srcdoc: "about:srcdoc", + aboutBlank: "about:blank", + }; + + let { contentPage, requests } = await loadURL(urls.topLevel); + + let tests = [ + { + matches: ["http://example.com/data/*"], + contentScript: {}, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + frameID: 0, + }, + topLevel: true, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + allFrames: true, + }, + topLevel: true, + iframe: true, + aboutBlank: false, + srcdoc: false, + }, + + { + matches: ["http://example.com/data/*"], + contentScript: { + allFrames: true, + matchAboutBlank: true, + }, + topLevel: true, + iframe: true, + aboutBlank: true, + srcdoc: true, + }, + + { + matches: ["http://foo.com/data/*"], + contentScript: { + allFrames: true, + matchAboutBlank: true, + }, + topLevel: false, + iframe: false, + aboutBlank: false, + srcdoc: false, + }, + ]; + + // matchesWindowGlobal tests against content frames + await contentPage.spawn({ tests, urls }, args => { + this.windows = new Map(); + this.windows.set(this.content.location.href, this.content); + for (let c of Array.from(this.content.frames)) { + this.windows.set(c.location.href, c); + } + this.policy = new WebExtensionPolicy({ + id: "foo@bar.baz", + mozExtensionHostname: "88fb51cd-159f-4859-83db-7065485bc9b2", + baseURL: "file:///foo", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + }); + + let tests = args.tests.map(t => { + t.contentScript.matches = new MatchPatternSet(t.matches); + t.script = new WebExtensionContentScript(this.policy, t.contentScript); + return t; + }); + for (let [i, test] of tests.entries()) { + for (let [frame, url] of Object.entries(args.urls)) { + let should = test[frame] ? "should" : "should not"; + let wgc = this.windows.get(url).windowGlobalChild; + Assert.equal( + test.script.matchesWindowGlobal(wgc), + test[frame], + `Script ${i} ${should} match the ${frame} frame` + ); + } + } + }); + + // Parent tests against loadInfo + tests = tests.map(t => { + t.contentScript.matches = new MatchPatternSet(t.matches); + t.script = new WebExtensionContentScript(policy, t.contentScript); + return t; + }); + + for (let [i, test] of tests.entries()) { + for (let [frame, url] of Object.entries(urls)) { + let should = test[frame] ? "should" : "should not"; + + if (url.startsWith("http")) { + let request = requests.get(url); + + equal( + test.script.matchesLoadInfo(request.URI, request.loadInfo), + test[frame], + `Script ${i} ${should} match the request LoadInfo for ${frame} frame` + ); + } + } + } + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js new file mode 100644 index 0000000000..75c6edd9c4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_WebExtensionPolicy.js @@ -0,0 +1,376 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { newURI } = Services.io; + +add_task(async function test_WebExtensionPolicy() { + const id = "foo@bar.baz"; + const uuid = "ca9d3f23-125c-4b24-abfc-1ca2692b0610"; + + const baseURL = "file:///foo/"; + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURI = newURI(mozExtURL); + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + + localizeCallback(str) { + return `<${str}>`; + }, + + allowedOrigins: new MatchPatternSet(["http://foo.bar/", "*://*.baz/"], { + ignorePath: true, + }), + permissions: ["<all_urls>"], + webAccessibleResources: ["/foo/*", "/bar.baz"].map( + glob => new MatchGlob(glob) + ), + }); + + equal(policy.active, false, "Active attribute should initially be false"); + + // GetURL + + equal( + policy.getURL(), + mozExtURL, + "getURL() should return the correct root URL" + ); + equal( + policy.getURL("path/foo.html"), + `${mozExtURL}path/foo.html`, + "getURL(path) should return the correct URL" + ); + + // Permissions + + deepEqual( + policy.permissions, + ["<all_urls>"], + "Initial permissions should be correct" + ); + + ok( + policy.hasPermission("<all_urls>"), + "hasPermission should match existing permission" + ); + ok( + !policy.hasPermission("history"), + "hasPermission should not match nonexistent permission" + ); + + Assert.throws( + () => { + policy.permissions[0] = "foo"; + }, + TypeError, + "Permissions array should be frozen" + ); + + policy.permissions = ["history"]; + deepEqual( + policy.permissions, + ["history"], + "Permissions should be updateable as a set" + ); + + ok( + policy.hasPermission("history"), + "hasPermission should match existing permission" + ); + ok( + !policy.hasPermission("<all_urls>"), + "hasPermission should not match nonexistent permission" + ); + + // Origins + + ok( + policy.canAccessURI(newURI("http://foo.bar/quux")), + "Should be able to access permitted URI" + ); + ok( + policy.canAccessURI(newURI("https://x.baz/foo")), + "Should be able to access permitted URI" + ); + + ok( + !policy.canAccessURI(newURI("https://foo.bar/quux")), + "Should not be able to access non-permitted URI" + ); + + policy.allowedOrigins = new MatchPatternSet(["https://foo.bar/"], { + ignorePath: true, + }); + + ok( + policy.canAccessURI(newURI("https://foo.bar/quux")), + "Should be able to access updated permitted URI" + ); + ok( + !policy.canAccessURI(newURI("https://x.baz/foo")), + "Should not be able to access removed permitted URI" + ); + + // Web-accessible resources + + ok( + policy.isPathWebAccessible("/foo/bar"), + "Web-accessible glob should be web-accessible" + ); + ok( + policy.isPathWebAccessible("/bar.baz"), + "Web-accessible path should be web-accessible" + ); + ok( + !policy.isPathWebAccessible("/bar.baz/quux"), + "Non-web-accessible path should not be web-accessible" + ); + + // Localization + + equal( + policy.localize("foo"), + "<foo>", + "Localization callback should work as expected" + ); + + // Protocol and lookups. + + let proto = Services.io + .getProtocolHandler("moz-extension", uuid) + .QueryInterface(Ci.nsISubstitutingProtocolHandler); + + deepEqual( + WebExtensionPolicy.getActiveExtensions(), + [], + "Should have no active extensions" + ); + equal( + WebExtensionPolicy.getByID(id), + null, + "ID lookup should not return extension when not active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + null, + "Hostname lookup should not return extension when not active" + ); + Assert.throws( + () => proto.resolveURI(mozExtURI), + /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active" + ); + + policy.active = true; + equal(policy.active, true, "Active attribute should be updated"); + + let exts = WebExtensionPolicy.getActiveExtensions(); + equal(exts.length, 1, "Should have one active extension"); + equal(exts[0], policy, "Should have the correct active extension"); + + equal( + WebExtensionPolicy.getByID(id), + policy, + "ID lookup should return extension when active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + policy, + "Hostname lookup should return extension when active" + ); + + equal( + proto.resolveURI(mozExtURI), + baseURL, + "URL should resolve correctly while active" + ); + + policy.active = false; + equal(policy.active, false, "Active attribute should be updated"); + + deepEqual( + WebExtensionPolicy.getActiveExtensions(), + [], + "Should have no active extensions" + ); + equal( + WebExtensionPolicy.getByID(id), + null, + "ID lookup should not return extension when not active" + ); + equal( + WebExtensionPolicy.getByHostname(uuid), + null, + "Hostname lookup should not return extension when not active" + ); + Assert.throws( + () => proto.resolveURI(mozExtURI), + /NS_ERROR_NOT_AVAILABLE/, + "URL should not resolve when not active" + ); + + // Conflicting policies. + + // This asserts in debug builds, so only test in non-debug builds. + if (!AppConstants.DEBUG) { + policy.active = true; + + let attrs = [ + { id, uuid }, + { id, uuid: "d916886c-cfdf-482e-b7b1-d7f5b0facfa5" }, + { id: "foo@quux", uuid }, + ]; + + // eslint-disable-next-line no-shadow + for (let { id, uuid } of attrs) { + let policy2 = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL: "file://bar/", + + localizeCallback() {}, + + allowedOrigins: new MatchPatternSet([]), + }); + + Assert.throws( + () => { + policy2.active = true; + }, + /NS_ERROR_UNEXPECTED/, + `Should not be able to activate conflicting policy: ${id} ${uuid}` + ); + } + + policy.active = false; + } +}); + +add_task(async function test_WebExtensionPolicy_registerContentScripts() { + const id = "foo@bar.baz"; + const uuid = "77a7b9d3-e73c-4cf3-97fb-1824868fe00f"; + + const id2 = "foo-2@bar.baz"; + const uuid2 = "89383c45-7db4-4999-83f7-f4cc246372cd"; + + const baseURL = "file:///foo/"; + + const mozExtURL = `moz-extension://${uuid}/`; + const mozExtURL2 = `moz-extension://${uuid2}/`; + + let policy = new WebExtensionPolicy({ + id, + mozExtensionHostname: uuid, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + + let policy2 = new WebExtensionPolicy({ + id: id2, + mozExtensionHostname: uuid2, + baseURL, + localizeCallback() {}, + allowedOrigins: new MatchPatternSet([]), + permissions: ["<all_urls>"], + }); + + let script1 = new WebExtensionContentScript(policy, { + run_at: "document_end", + js: [`${mozExtURL}/registered-content-script.js`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + let script2 = new WebExtensionContentScript(policy, { + run_at: "document_end", + css: [`${mozExtURL}/registered-content-style.css`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + let script3 = new WebExtensionContentScript(policy2, { + run_at: "document_end", + css: [`${mozExtURL2}/registered-content-style.css`], + matches: new MatchPatternSet(["http://localhost/data/*"]), + }); + + deepEqual( + policy.contentScripts, + [], + "The policy contentScripts is initially empty" + ); + + policy.registerContentScript(script1); + + deepEqual( + policy.contentScripts, + [script1], + "script1 has been added to the policy contentScripts" + ); + + Assert.throws( + () => policy.registerContentScript(script1), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script more than once" + ); + + Assert.throws( + () => policy.registerContentScript(script3), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to register a script related to " + + "a different extension" + ); + + Assert.throws( + () => policy.unregisterContentScript(script3), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script related to " + + "a different extension" + ); + + deepEqual( + policy.contentScripts, + [script1], + "script1 has not been added twice" + ); + + policy.registerContentScript(script2); + + deepEqual( + policy.contentScripts, + [script1, script2], + "script2 has the last item of the policy contentScripts array" + ); + + policy.unregisterContentScript(script1); + + deepEqual( + policy.contentScripts, + [script2], + "script1 has been removed from the policy contentscripts" + ); + + Assert.throws( + () => policy.unregisterContentScript(script1), + e => e.result == Cr.NS_ERROR_ILLEGAL_VALUE, + "Got the expected NS_ERROR_ILLEGAL_VALUE when trying to unregister a script more than once" + ); + + deepEqual( + policy.contentScripts, + [script2], + "the policy contentscripts is unmodified when unregistering an unknown contentScript" + ); + + policy.unregisterContentScript(script2); + + deepEqual( + policy.contentScripts, + [], + "script2 has been removed from the policy contentScripts" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js new file mode 100644 index 0000000000..a6d22e8703 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_change_remote_mode.js @@ -0,0 +1,20 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function change_remote() { + let remote = Services.prefs.getBoolPref("extensions.webextensions.remote"); + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + remote, + "value of useRemoteWebExtensions matches the pref" + ); + + Services.prefs.setBoolPref("extensions.webextensions.remote", !remote); + + Assert.equal( + WebExtensionPolicy.useRemoteWebExtensions, + remote, + "value of useRemoteWebExtensions is still the same after changing the pref" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js new file mode 100644 index 0000000000..0b24cc4c50 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_custom_policies.js @@ -0,0 +1,278 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +const ADDON_ID = "test@web.extension"; + +const aps = Cc["@mozilla.org/addons/policy-service;1"].getService( + Ci.nsIAddonPolicyService +); + +const v2_csp = Preferences.get( + "extensions.webextensions.base-content-security-policy" +); +const v3_csp = Preferences.get( + "extensions.webextensions.base-content-security-policy.v3" +); + +add_task(async function test_invalid_addon_csp() { + await Assert.throws( + () => aps.getBaseCSP("invalid@missing"), + /NS_ERROR_ILLEGAL_VALUE/, + "no base csp for non-existent addon" + ); + await Assert.throws( + () => aps.getExtensionPageCSP("invalid@missing"), + /NS_ERROR_ILLEGAL_VALUE/, + "no extension page csp for non-existent addon" + ); +}); + +add_task(async function test_policy_csp() { + equal( + aps.defaultCSP, + Preferences.get("extensions.webextensions.default-content-security-policy"), + "Expected default CSP value" + ); + + const CUSTOM_POLICY = + "script-src: 'self' https://xpcshell.test.custom.csp; object-src: 'none'"; + + let tests = [ + { + name: "manifest version 2, no custom policy", + policyData: {}, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest version 2, no custom policy", + policyData: { + manifestVersion: 2, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "version 2 custom extension policy", + policyData: { + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + { + name: "manifest version 2 set, custom extension policy", + policyData: { + manifestVersion: 2, + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + { + name: "manifest version 3, no custom policy", + policyData: { + manifestVersion: 3, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest 3 version set, custom extensionPage policy", + policyData: { + manifestVersion: 3, + extensionPageCSP: CUSTOM_POLICY, + }, + expectedPolicy: CUSTOM_POLICY, + }, + ]; + + let policy = null; + + function setExtensionCSP({ manifestVersion, extensionPageCSP }) { + if (policy) { + policy.active = false; + } + + policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: ADDON_ID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + localizeCallback() {}, + + manifestVersion, + extensionPageCSP, + }); + + policy.active = true; + } + + for (let test of tests) { + info(test.name); + setExtensionCSP(test.policyData); + equal( + aps.getBaseCSP(ADDON_ID), + test.policyData.manifestVersion == 3 ? v3_csp : v2_csp, + "baseCSP is correct" + ); + equal( + aps.getExtensionPageCSP(ADDON_ID), + test.expectedPolicy, + "extensionPageCSP is correct" + ); + } +}); + +add_task(async function test_extension_csp() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + + ExtensionTestUtils.failOnSchemaWarnings(false); + + let extension_pages = "script-src 'self'; object-src 'none'; img-src 'none'"; + + let tests = [ + { + name: "manifest_v2 invalid csp results in default csp used", + manifest: { + content_security_policy: `script-src 'none'`, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v2 allows https protocol", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://*; object-src 'self'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v2 allows unsafe-eval", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'unsafe-eval'; object-src 'self'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 invalid csp results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'none'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 forbidden protocol results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://*; object-src 'self'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 forbidden eval results in default csp used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' 'unsafe-eval'; object-src 'self'`, + }, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 allows localhost", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://localhost; object-src 'self'`, + }, + }, + expectedPolicy: `script-src 'self' https://localhost; object-src 'self'`, + }, + { + name: "manifest_v3 allows 127.0.0.1", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages: `script-src 'self' https://127.0.0.1; object-src 'self'`, + }, + }, + expectedPolicy: `script-src 'self' https://127.0.0.1; object-src 'self'`, + }, + { + name: "manifest_v2 csp", + manifest: { + manifest_version: 2, + content_security_policy: extension_pages, + }, + expectedPolicy: extension_pages, + }, + { + name: "manifest_v2 with no csp, expect default", + manifest: { + manifest_version: 2, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 used with no csp, expect default", + manifest: { + manifest_version: 3, + }, + expectedPolicy: aps.defaultCSP, + }, + { + name: "manifest_v3 used with v2 syntax", + manifest: { + manifest_version: 3, + content_security_policy: extension_pages, + }, + expectedPolicy: extension_pages, + }, + { + name: "manifest_v3 syntax used", + manifest: { + manifest_version: 3, + content_security_policy: { + extension_pages, + }, + }, + expectedPolicy: extension_pages, + }, + ]; + + for (let test of tests) { + info(test.name); + let extension = ExtensionTestUtils.loadExtension({ + manifest: test.manifest, + }); + await extension.startup(); + let policy = WebExtensionPolicy.getByID(extension.id); + equal( + policy.baseCSP, + test.manifest.manifest_version == 3 ? v3_csp : v2_csp, + "baseCSP is correct" + ); + equal( + policy.extensionPageCSP, + test.expectedPolicy, + "extensionPageCSP is correct." + ); + await extension.unload(); + } + + ExtensionTestUtils.failOnSchemaWarnings(true); + + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_csp_validator.js b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js new file mode 100644 index 0000000000..fb494f3da2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_csp_validator.js @@ -0,0 +1,298 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const cps = Cc["@mozilla.org/addons/content-policy;1"].getService( + Ci.nsIAddonContentPolicy +); + +add_task(async function test_csp_validator_flags() { + let checkPolicy = (policy, flags, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP(policy, flags); + equal(result, expectedResult); + }; + + let flags = Ci.nsIAddonContentPolicy; + + checkPolicy( + "default-src 'self'; script-src 'self' http://localhost", + 0, + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "localhost disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' http://localhost", + flags.CSP_ALLOW_LOCALHOST, + null, + "localhost allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval'", + 0, + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword", + "eval disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' 'unsafe-eval'", + flags.CSP_ALLOW_EVAL, + null, + "eval allowed" + ); + + checkPolicy( + "default-src 'self'; script-src 'self' https://example.com", + 0, + "\u2018script-src\u2019 directive contains a forbidden https: protocol source", + "remote disallowed" + ); + checkPolicy( + "default-src 'self'; script-src 'self' https://example.com", + flags.CSP_ALLOW_REMOTE, + null, + "remote allowed" + ); +}); + +add_task(async function test_csp_validator() { + let checkPolicy = (policy, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP( + policy, + Ci.nsIAddonContentPolicy.CSP_ALLOW_ANY + ); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self'; object-src 'self';", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash} 'unsafe-eval'; ` + + `object-src 'self' https://com https://*.example.com moz-extension://09abcdef blob: filesystem: ${hash}`, + null + ); + + checkPolicy( + "", + "Policy is missing a required \u2018script-src\u2019 directive" + ); + + checkPolicy( + "object-src 'none';", + "Policy is missing a required \u2018script-src\u2019 directive" + ); + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src or object-src" + ); + + checkPolicy( + "default-src 'self'; script-src 'self'", + null, + "A valid default-src should count as a valid script-src or object-src" + ); + + checkPolicy( + "default-src 'self'; object-src 'self'", + null, + "A valid default-src should count as a valid script-src or object-src" + ); + + checkPolicy( + "default-src 'self'; script-src http://example.com", + "\u2018script-src\u2019 directive contains a forbidden http: protocol source", + "A valid default-src should not allow an invalid script-src directive" + ); + + checkPolicy( + "default-src 'self'; object-src http://example.com", + "\u2018object-src\u2019 directive contains a forbidden http: protocol source", + "A valid default-src should not allow an invalid object-src directive" + ); + + checkPolicy( + "script-src 'self';", + "Policy is missing a required \u2018object-src\u2019 directive" + ); + + checkPolicy( + "script-src 'none'; object-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy("script-src 'self'; object-src 'none';", null); + + checkPolicy( + "script-src 'self' 'unsafe-inline'; object-src 'self';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + // Localhost is always valid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + checkPolicy(`script-src 'self' ${src}; object-src 'none';`, null); + } + + let directives = ["script-src", "object-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let src of ["https://*", "https://*.blogspot.com", "https://*"]) { + checkPolicy( + `${directive} 'self' ${src}; ${other} 'self';`, + `https: wildcard sources in \u2018${directive}\u2019 directives must include at least one non-generic sub-domain (e.g., *.example.com rather than *.com)` + ); + } + + for (let protocol of ["http", "https"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives` + ); + } + + checkPolicy( + `${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source` + ); + + for (let protocol of ["ftp", "meh"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + checkPolicy( + `${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword` + ); + } +}); + +add_task(async function test_csp_validator_extension_pages() { + let checkPolicy = (policy, expectedResult, message = null) => { + info(`Checking policy: ${policy}`); + + let result = cps.validateAddonCSP( + policy, + Ci.nsIAddonContentPolicy.CSP_ALLOW_LOCALHOST + ); + equal(result, expectedResult); + }; + + checkPolicy("script-src 'self'; object-src 'self';", null); + checkPolicy("script-src 'self'; object-src 'self'; worker-src 'none'", null); + checkPolicy("script-src 'self'; object-src 'none'; worker-src 'self'", null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + checkPolicy( + `script-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}; ` + + `object-src 'self' moz-extension://09abcdef blob: filesystem: ${hash}`, + null + ); + + for (let policy of ["", "object-src 'none';", "worker-src 'none';"]) { + checkPolicy( + policy, + "Policy is missing a required \u2018script-src\u2019 directive" + ); + } + + checkPolicy( + "default-src 'self'", + null, + "A valid default-src should count as a valid script-src or object-src" + ); + + for (let directive of ["script-src", "object-src", "worker-src"]) { + checkPolicy( + `default-src 'self'; ${directive} 'self'`, + null, + `A valid default-src should count as a valid ${directive}` + ); + checkPolicy( + `default-src 'self'; ${directive} http://example.com`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source`, + `A valid default-src should not allow an invalid ${directive} directive` + ); + } + + checkPolicy( + "script-src 'self';", + "Policy is missing a required \u2018object-src\u2019 directive" + ); + + checkPolicy( + "script-src 'none'; object-src 'none'", + "\u2018script-src\u2019 must include the source 'self'" + ); + + checkPolicy("script-src 'self'; object-src 'none';", null); + + checkPolicy( + "script-src 'self' 'unsafe-inline'; object-src 'self';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-inline' keyword" + ); + + checkPolicy( + "script-src 'self' 'unsafe-eval'; object-src 'self';", + "\u2018script-src\u2019 directive contains a forbidden 'unsafe-eval' keyword" + ); + + // Localhost is always valid + for (let src of [ + "http://localhost", + "https://localhost", + "http://127.0.0.1", + "https://127.0.0.1", + ]) { + checkPolicy(`script-src 'self' ${src}; object-src 'none';`, null); + } + + let directives = ["script-src", "object-src"]; + + for (let [directive, other] of [directives, directives.slice().reverse()]) { + for (let protocol of ["http", "https"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `${protocol}: protocol requires a host in \u2018${directive}\u2019 directives` + ); + } + + checkPolicy( + `${directive} 'self' https://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden https: protocol source` + ); + + checkPolicy( + `${directive} 'self' http://example.com; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden http: protocol source` + ); + + for (let protocol of ["ftp", "meh"]) { + checkPolicy( + `${directive} 'self' ${protocol}:; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden ${protocol}: protocol source` + ); + } + + checkPolicy( + `${directive} 'self' 'nonce-01234'; ${other} 'self';`, + `\u2018${directive}\u2019 directive contains a forbidden 'nonce-*' keyword` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js new file mode 100644 index 0000000000..20ffb71d18 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_MessageManagerProxy.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { MessageManagerProxy } = ChromeUtils.import( + "resource://gre/modules/MessageManagerProxy.jsm" +); +const { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +class TestMessageManagerProxy extends MessageManagerProxy { + constructor(contentPage, identifier) { + super(contentPage.browser); + this.identifier = identifier; + this.contentPage = contentPage; + this.deferred = null; + } + + // Registers message listeners. Call dispose() once you've finished. + async setupPingPongListeners() { + await this.contentPage.loadFrameScript(`() => { + this.addMessageListener("test:MessageManagerProxy:Ping", ({data}) => { + this.sendAsyncMessage("test:MessageManagerProxy:Pong", "${this.identifier}:" + data); + }); + }`); + + // Register the listener here instead of during testPingPong, to make sure + // that the listener is correctly registered during the whole test. + this.addMessageListener("test:MessageManagerProxy:Pong", event => { + ok( + this.deferred, + `[${this.identifier}] expected to be waiting for ping-pong` + ); + this.deferred.resolve(event.data); + this.deferred = null; + }); + } + + async testPingPong(description) { + equal(this.deferred, null, "should not be waiting for a message"); + this.deferred = PromiseUtils.defer(); + this.sendAsyncMessage("test:MessageManagerProxy:Ping", description); + let result = await this.deferred.promise; + equal(result, `${this.identifier}:${description}`, "Expected ping-pong"); + } +} + +// Tests that MessageManagerProxy continues to proxy messages after docshells +// have been swapped. +add_task(async function test_message_after_swapdocshells() { + let page1 = await ExtensionTestUtils.loadContentPage("about:blank"); + let page2 = await ExtensionTestUtils.loadContentPage("about:blank"); + + let testProxyOne = new TestMessageManagerProxy(page1, "page1"); + let testProxyTwo = new TestMessageManagerProxy(page2, "page2"); + + await testProxyOne.setupPingPongListeners(); + await testProxyTwo.setupPingPongListeners(); + + await testProxyOne.testPingPong("after setup (to 1)"); + await testProxyTwo.testPingPong("after setup (to 2)"); + + page1.browser.swapDocShells(page2.browser); + + await testProxyOne.testPingPong("after docshell swap (to 1)"); + await testProxyTwo.testPingPong("after docshell swap (to 2)"); + + // Swap again to verify that listeners are repeatedly moved when needed. + page1.browser.swapDocShells(page2.browser); + + await testProxyOne.testPingPong("after another docshell swap (to 1)"); + await testProxyTwo.testPingPong("after another docshell swap (to 2)"); + + // Verify that dispose() works regardless of the browser's validity. + await testProxyOne.dispose(); + await page1.close(); + await page2.close(); + await testProxyTwo.dispose(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js new file mode 100644 index 0000000000..4a23b65264 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_activityLog.js @@ -0,0 +1,21 @@ +"use strict"; + +add_task(async function test_api_restricted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { id: "activityLog-permission@tests.mozilla.org" }, + }, + permissions: ["activityLog"], + }, + async background() { + browser.test.assertEq( + undefined, + browser.activityLog, + "activityLog is privileged" + ); + }, + }); + await extension.startup(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js new file mode 100644 index 0000000000..bc4e0409cb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_private_field_xrays.js @@ -0,0 +1,160 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_private_field_xrays() { + async function contentScript() { + let node = window.document.createElement("div"); + + class Base { + constructor(o) { + return o; + } + } + + class A extends Base { + #x = 5; + static gx(o) { + return o.#x; + } + static sx(o, v) { + o.#x = v; + } + } + + browser.test.log(A.toString()); + + // Stamp node with A's private field. + new A(node); + + browser.test.log("stamped"); + + browser.test.assertEq( + A.gx(node), + 5, + "We should be able to see our expando private field" + ); + browser.test.log("Read"); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Underlying object should not have our private field" + ); + + browser.test.log("threw"); + window.frames[0].document.adoptNode(node); + browser.test.log("adopted"); + browser.test.assertEq( + A.gx(node), + 5, + "Adoption should not change expando private field" + ); + browser.test.log("read"); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Adoption should really not change expandos private fields" + ); + browser.test.log("threw2"); + + // Repeat but now with an object that has a reference from the + // window it's being cloned into. + node = window.document.createElement("div"); + // Stamp node with A's private field. + new A(node); + A.sx(node, 6); + + browser.test.assertEq( + A.gx(node), + 6, + "We should be able to see our expando (2)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Underlying object should not have exxpando. (2)" + ); + + window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject; + window.frames[0].document.adoptNode(node); + + browser.test.assertEq( + A.gx(node), + 6, + "We should be able to see our expando (3)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Underlying object should not have exxpando. (3)" + ); + + // Repeat once more, now with an expando that refers to the object itself + node = window.document.createElement("div"); + new A(node); + A.sx(node, node); + + browser.test.assertEq( + A.gx(node), + node, + "We should be able to see our self-referential expando (4)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Underlying object should not have exxpando. (4)" + ); + + window.frames[0].document.adoptNode(node); + + browser.test.assertEq( + A.gx(node), + node, + "Adoption should not change our self-referential expando (4)" + ); + browser.test.assertThrows( + () => A.gx(node.wrappedJSObject), + /Trying to read undeclared field/, + "Adoption should not change underlying object. (4)" + ); + + // And test what happens if we now set document.domain and cause + // wrapper remapping. + let doc = window.frames[0].document; + // eslint-disable-next-line no-self-assign + doc.domain = doc.domain; + + browser.test.notifyPass("privateFieldXRayAdoption"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_toplevel.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + + await extension.awaitFinish("privateFieldXRayAdoption"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js new file mode 100644 index 0000000000..9655c157d1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_adoption_with_xrays.js @@ -0,0 +1,129 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_xrays() { + async function contentScript() { + let node = window.document.createElement("div"); + node.expando = 5; + + browser.test.assertEq( + node.expando, + 5, + "We should be able to see our expando" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our expando" + ); + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + 5, + "Adoption should not change expandos" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change expandos" + ); + + // Repeat but now with an object that has a reference from the + // window it's being cloned into. + node = window.document.createElement("div"); + node.expando = 6; + + browser.test.assertEq( + node.expando, + 6, + "We should be able to see our expando (2)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our expando (2)" + ); + + window.frames[0].wrappedJSObject.incoming = node.wrappedJSObject; + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + 6, + "Adoption should not change expandos (2)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change expandos (2)" + ); + + // Repeat once more, now with an expando that refers to the object itself. + node = window.document.createElement("div"); + node.expando = node; + + browser.test.assertEq( + node.expando, + node, + "We should be able to see our self-referential expando (3)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Underlying object should not have our self-referential expando (3)" + ); + + window.frames[0].document.adoptNode(node); + browser.test.assertEq( + node.expando, + node, + "Adoption should not change self-referential expando (3)" + ); + browser.test.assertEq( + node.wrappedJSObject.expando, + undefined, + "Adoption should really not change self-referential expando (3)" + ); + + // And test what happens if we now set document.domain and cause + // wrapper remapping. + let doc = window.frames[0].document; + // eslint-disable-next-line no-self-assign + doc.domain = doc.domain; + + browser.test.notifyPass("contentScriptAdoptionWithXrays"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_toplevel.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + + await extension.awaitFinish("contentScriptAdoptionWithXrays"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js new file mode 100644 index 0000000000..0751f7d573 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms.js @@ -0,0 +1,219 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_alarm_without_permissions() { + function backgroundScript() { + browser.test.assertTrue( + !browser.alarms, + "alarm API is not available when the alarm permission is not required" + ); + browser.test.notifyPass("alarms_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarms_permission"); + await extension.unload(); +}); + +add_task(async function test_alarm_clear_non_matching_name() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.create(ALARM_NAME, { when: Date.now() + 2000000 }); + + let wasCleared = await browser.alarms.clear(ALARM_NAME + "1"); + browser.test.assertFalse(wasCleared, "alarm was not cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(1, alarms.length, "alarm was not removed"); + browser.test.notifyPass("alarm-clear"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-clear"); + await extension.unload(); +}); + +add_task(async function test_alarm_get_and_clear_single_argument() { + async function backgroundScript() { + browser.alarms.create({ when: Date.now() + 2000000 }); + + let alarm = await browser.alarms.get(); + browser.test.assertEq("", alarm.name, "expected alarm returned"); + + let wasCleared = await browser.alarms.clear(); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "alarm was removed"); + + browser.test.notifyPass("alarm-single-arg"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-single-arg"); + await extension.unload(); +}); + +add_task(async function test_get_get_all_clear_all_alarms() { + async function backgroundScript() { + const ALARM_NAME = "test_alarm"; + + let suffixes = [0, 1, 2]; + + for (let suffix of suffixes) { + browser.alarms.create(ALARM_NAME + suffix, { + when: Date.now() + (suffix + 1) * 10000, + }); + } + + let alarms = await browser.alarms.getAll(); + browser.test.assertEq( + suffixes.length, + alarms.length, + "expected number of alarms were found" + ); + alarms.forEach((alarm, index) => { + browser.test.assertEq( + ALARM_NAME + index, + alarm.name, + "alarm has the expected name" + ); + }); + + for (let suffix of suffixes) { + let alarm = await browser.alarms.get(ALARM_NAME + suffix); + browser.test.assertEq( + ALARM_NAME + suffix, + alarm.name, + "alarm has the expected name" + ); + browser.test.sendMessage(`get-${suffix}`); + } + + let wasCleared = await browser.alarms.clear(ALARM_NAME + suffixes[0]); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(2, alarms.length, "alarm was removed"); + + let alarm = await browser.alarms.get(ALARM_NAME + suffixes[0]); + browser.test.assertEq(undefined, alarm, "non-existent alarm is undefined"); + browser.test.sendMessage(`get-invalid`); + + wasCleared = await browser.alarms.clearAll(); + browser.test.assertTrue(wasCleared, "alarms were cleared"); + + alarms = await browser.alarms.getAll(); + browser.test.assertEq(0, alarms.length, "no alarms exist"); + browser.test.sendMessage("clearAll"); + browser.test.sendMessage("clear"); + browser.test.sendMessage("getAll"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("getAll"), + extension.awaitMessage("get-0"), + extension.awaitMessage("get-1"), + extension.awaitMessage("get-2"), + extension.awaitMessage("clear"), + extension.awaitMessage("get-invalid"), + extension.awaitMessage("clearAll"), + ]); + await extension.unload(); +}); + +async function test_alarm_fires_with_options(alarmCreateOptions) { + info( + `Test alarms.create fires with options: ${JSON.stringify( + alarmCreateOptions + )}` + ); + + function backgroundScript(createOptions) { + let ALARM_NAME = "test_ext_alarms"; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq( + ALARM_NAME, + alarm.name, + "alarm has the expected name" + ); + clearTimeout(timer); + browser.test.notifyPass("alarms-create-with-options"); + }); + + browser.alarms.create(ALARM_NAME, createOptions); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired within expected time"); + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + browser.test.notifyFail("alarms-create-with-options"); + }, 10000); + } + + let extension = ExtensionTestUtils.loadExtension({ + // Pass the alarms.create options to the background page. + background: `(${backgroundScript})(${JSON.stringify(alarmCreateOptions)})`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarms-create-with-options"); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +} + +add_task(async function test_alarm_fires() { + Services.prefs.setBoolPref( + "privacy.resistFingerprinting.reduceTimerPrecision.jitter", + false + ); + + await test_alarm_fires_with_options({ delayInMinutes: 0.01 }); + await test_alarm_fires_with_options({ when: Date.now() + 1000 }); + await test_alarm_fires_with_options({ delayInMinutes: -10 }); + await test_alarm_fires_with_options({ when: Date.now() - 1000 }); + + Services.prefs.clearUserPref( + "privacy.resistFingerprinting.reduceTimerPrecision.jitter" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js new file mode 100644 index 0000000000..fe385004ba --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_does_not_fire.js @@ -0,0 +1,34 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_cleared_alarm_does_not_fire() { + async function backgroundScript() { + let ALARM_NAME = "test_ext_alarms"; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.fail("cleared alarm does not fire"); + browser.test.notifyFail("alarm-cleared"); + }); + browser.alarms.create(ALARM_NAME, { when: Date.now() + 1000 }); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + await new Promise(resolve => setTimeout(resolve, 2000)); + + browser.test.notifyPass("alarm-cleared"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-cleared"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js new file mode 100644 index 0000000000..b78d6da649 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_periodic.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_periodic_alarm_fires() { + function backgroundScript() { + const ALARM_NAME = "test_ext_alarms"; + let count = 0; + let timer; + + browser.alarms.onAlarm.addListener(alarm => { + browser.test.assertEq( + alarm.name, + ALARM_NAME, + "alarm has the expected name" + ); + if (count++ === 3) { + clearTimeout(timer); + browser.alarms.clear(ALARM_NAME).then(wasCleared => { + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyPass("alarm-periodic"); + }); + } + }); + + browser.alarms.create(ALARM_NAME, { periodInMinutes: 0.02 }); + + timer = setTimeout(async () => { + browser.test.fail("alarm fired expected number of times"); + + let wasCleared = await browser.alarms.clear(ALARM_NAME); + browser.test.assertTrue(wasCleared, "alarm was cleared"); + + browser.test.notifyFail("alarm-periodic"); + }, 30000); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-periodic"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js new file mode 100644 index 0000000000..0d7597fa5a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_alarms_replaces.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_duplicate_alarm_name_replaces_alarm() { + function backgroundScript() { + let count = 0; + + browser.alarms.onAlarm.addListener(async alarm => { + browser.test.assertEq( + "replaced alarm", + alarm.name, + "Expected last alarm" + ); + browser.test.assertEq( + 0, + count++, + "duplicate named alarm replaced existing alarm" + ); + let results = await browser.alarms.getAll(); + + // "replaced alarm" is expected to be replaced with a non-repeating + // alarm, so it should not appear in the list of alarms. + browser.test.assertEq(1, results.length, "exactly one alarms exists"); + browser.test.assertEq( + "unrelated alarm", + results[0].name, + "remaining alarm has the expected name" + ); + + browser.test.notifyPass("alarm-duplicate"); + }); + + // Alarm that is so far in the future that it is never triggered. + browser.alarms.create("unrelated alarm", { delayInMinutes: 60 }); + // Alarm that repeats. + browser.alarms.create("replaced alarm", { + delayInMinutes: 1 / 60, + periodInMinutes: 1 / 60, + }); + // Before the repeating alarm is triggered, it is immediately replaced with + // a non-repeating alarm. + browser.alarms.create("replaced alarm", { delayInMinutes: 3 / 60 }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["alarms"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("alarm-duplicate"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js new file mode 100644 index 0000000000..4be29dc848 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_api_permissions.js @@ -0,0 +1,76 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null +); +function getNextContext() { + return new Promise(resolve => { + Management.on("proxy-context-load", function listener(type, context) { + Management.off("proxy-context-load", listener); + resolve(context); + }); + }); +} + +add_task(async function test_storage_api_without_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + // Force API initialization. + try { + browser.storage.onChanged.addListener(() => {}); + } catch (e) { + // Ignore. + } + }, + + manifest: { + permissions: [], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + ok( + !("storage" in context.apiObj), + "The storage API should not be initialized" + ); + + await extension.unload(); +}); + +add_task(async function test_storage_api_with_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.storage.onChanged.addListener(() => {}); + }, + + manifest: { + permissions: ["storage"], + }, + }); + + let contextPromise = getNextContext(); + await extension.startup(); + + let context = await contextPromise; + + // Force API initialization. + void context.apiObj; + + equal( + typeof context.apiObj.storage, + "object", + "The storage API should be initialized" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js new file mode 100644 index 0000000000..a603b03a29 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_api_injection.js @@ -0,0 +1,35 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function testBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + window.location = + "http://example.com/data/file_privilege_escalation.html"; + }, + }); + + let awaitConsole = new Promise(resolve => { + Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + resolve(message); + } + }); + }); + + await extension.startup(); + + let message = await awaitConsole; + ok( + message.message.includes( + "WebExt Privilege Escalation: typeof(browser) = undefined" + ), + "Document does not have `browser` APIs." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js new file mode 100644 index 0000000000..ec9d9a6c43 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_early_shutdown.js @@ -0,0 +1,195 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { BrowserTestUtils } = ChromeUtils.import( + "resource://testing-common/BrowserTestUtils.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null +); + +// Crashes a <browser>'s remote process. +// Based on BrowserTestUtils.crashFrame. +function crashFrame(browser) { + if (!browser.isRemoteBrowser) { + // The browser should be remote, or the test runner would be killed. + throw new Error("<browser> must be remote"); + } + + // Trigger crash by sending a message to BrowserTestUtils actor. + BrowserTestUtils.sendAsyncMessage( + browser.browsingContext, + "BrowserTestUtils:CrashFrame", + {} + ); +} + +// Verifies that a delayed background page is not loaded when an extension is +// shut down during startup. +add_task(async function test_unload_extension_before_background_page_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + browser.test.sendMessage("background_startup_observed"); + }, + }); + + // Delayed startup are only enabled for browser (re)starts, so we need to + // install the extension first, and then unload it. + + await extension.startup(); + await extension.awaitMessage("background_startup_observed"); + + // Now the actual test: Unloading an extension before the startup has + // finished should interrupt the start-up and abort pending delayed loads. + info("Starting extension whose startup will be interrupted"); + ExtensionParent._resetStartupPromises(); + await promiseRestartManager(); + await extension.awaitStartup(); + + let extensionBrowserInsertions = 0; + let onExtensionBrowserInserted = () => ++extensionBrowserInsertions; + Management.on("extension-browser-inserted", onExtensionBrowserInserted); + + info("Unloading extension before the delayed background page starts loading"); + await extension.addon.disable(); + + // Re-enable the add-on to let enough time pass to load a whole background + // page. If at the end of this the original background page hasn't loaded, + // we can consider the test successful. + await extension.addon.enable(); + + // Trigger the notification that would load a background page. + info("Forcing pending delayed background page to load"); + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + + // This is the expected message from the re-enabled add-on. + await extension.awaitMessage("background_startup_observed"); + await extension.unload(); + + await promiseShutdownManager(); + ExtensionParent._resetStartupPromises(); + + Management.off("extension-browser-inserted", onExtensionBrowserInserted); + Assert.equal( + extensionBrowserInsertions, + 1, + "Extension browser should have been inserted only once" + ); +}); + +// Verifies that the "build" method of BackgroundPage in ext-backgroundPage.js +// does not deadlock when startup is interrupted by extension shutdown. +add_task(async function test_unload_extension_during_background_page_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + browser.test.sendMessage("background_starting"); + }, + }); + + // Delayed startup are only enabled for browser (re)starts, so we need to + // install the extension first, and then reload it. + await extension.startup(); + await extension.awaitMessage("background_starting"); + + ExtensionParent._resetStartupPromises(); + await promiseRestartManager(); + await extension.awaitStartup(); + + let bgStartupPromise = new Promise(resolve => { + function onBackgroundPageDone(eventName) { + extension.extension.off("background-page-started", onBackgroundPageDone); + extension.extension.off("background-page-aborted", onBackgroundPageDone); + + if (eventName === "background-page-aborted") { + info("Background page startup was interrupted"); + resolve("bg_aborted"); + } else { + info("Background page startup finished normally"); + resolve("bg_fully_loaded"); + } + } + extension.extension.on("background-page-started", onBackgroundPageDone); + extension.extension.on("background-page-aborted", onBackgroundPageDone); + }); + + let bgStartingPromise = new Promise(resolve => { + let backgroundLoadCount = 0; + let backgroundPageUrl = extension.extension.baseURI.resolve( + "_generated_background_page.html" + ); + + // Prevent the background page from actually loading. + Management.once("extension-browser-inserted", (eventName, browser) => { + // Intercept background page load. + let browserLoadURI = browser.loadURI; + browser.loadURI = function() { + Assert.equal(++backgroundLoadCount, 1, "loadURI should be called once"); + Assert.equal( + arguments[0], + backgroundPageUrl, + "Expected background page" + ); + // Reset to "about:blank" to not load the actual background page. + arguments[0] = "about:blank"; + browserLoadURI.apply(this, arguments); + + // And force the extension process to crash. + if (browser.isRemote) { + crashFrame(browser); + } else { + // If extensions are not running in out-of-process mode, then the + // non-remote process should not be killed (or the test runner dies). + // Remove <browser> instead, to simulate the immediate disconnection + // of the message manager (that would happen if the process crashed). + browser.remove(); + } + resolve(); + }; + }); + }); + + // Force background page to initialize. + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + await bgStartingPromise; + + await extension.unload(); + await promiseShutdownManager(); + + // This part is the regression test for bug 1501375. It verifies that the + // background building completes eventually. + // If it does not, then the next line will cause a timeout. + info("Waiting for background builder to finish"); + let bgLoadState = await bgStartupPromise; + Assert.equal(bgLoadState, "bg_aborted", "Startup should be interrupted"); + + ExtensionParent._resetStartupPromises(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js new file mode 100644 index 0000000000..cac574b8ca --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_load_events.js @@ -0,0 +1,23 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_DOMContentLoaded_in_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + function reportListener(event) { + browser.test.sendMessage("eventname", event.type); + } + document.addEventListener("DOMContentLoaded", reportListener); + window.addEventListener("load", reportListener); + }, + }); + + await extension.startup(); + equal("DOMContentLoaded", await extension.awaitMessage("eventname")); + equal("load", await extension.awaitMessage("eventname")); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js new file mode 100644 index 0000000000..a22db9d582 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_generated_reload.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_reload_generated_background_page() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + if (location.hash !== "#firstrun") { + browser.test.sendMessage("first run"); + location.hash = "#firstrun"; + browser.test.assertEq("#firstrun", location.hash); + location.reload(); + } else { + browser.test.notifyPass("second run"); + } + }, + }); + + await extension.startup(); + await extension.awaitMessage("first run"); + await extension.awaitFinish("second run"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js new file mode 100644 index 0000000000..eaf20827e5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_global_history.js @@ -0,0 +1,24 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PlacesTestUtils } = ChromeUtils.import( + "resource://testing-common/PlacesTestUtils.jsm" +); + +add_task(async function test_global_history() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background-loaded", location.href); + }, + }); + + await extension.startup(); + + let backgroundURL = await extension.awaitMessage("background-loaded"); + + await extension.unload(); + + let exists = await PlacesTestUtils.isPageInDB(backgroundURL); + ok(!exists, "Background URL should not be in history database"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js new file mode 100644 index 0000000000..5075e643be --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_private_browsing.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_background_incognito() { + info( + "Test background page incognito value with permanent private browsing enabled" + ); + + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + async background() { + browser.test.assertEq( + window, + browser.extension.getBackgroundPage(), + "Caller should be able to access itself as a background page" + ); + browser.test.assertEq( + window, + await browser.runtime.getBackgroundPage(), + "Caller should be able to access itself as a background page" + ); + + browser.test.assertEq( + browser.extension.inIncognitoContext, + true, + "inIncognitoContext is true for permanent private browsing" + ); + + browser.test.notifyPass("incognito"); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("incognito"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js new file mode 100644 index 0000000000..aa0976434b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_runtime_connect_params.js @@ -0,0 +1,88 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let received_ports_number = 0; + + const expected_received_ports_number = 1; + + function countReceivedPorts(port) { + received_ports_number++; + + if (port.name == "check-results") { + browser.runtime.onConnect.removeListener(countReceivedPorts); + + browser.test.assertEq( + expected_received_ports_number, + received_ports_number, + "invalid connect should not create a port" + ); + + browser.test.notifyPass("runtime.connect invalid params"); + } + } + + browser.runtime.onConnect.addListener(countReceivedPorts); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); +} + +function senderScript() { + let detected_invalid_connect_params = 0; + + const invalid_connect_params = [ + // too many params + [ + "fake-extensions-id", + { name: "fake-conn-name" }, + "unexpected third params", + ], + // invalid params format + [{}, {}], + ["fake-extensions-id", "invalid-connect-info-format"], + ]; + const expected_detected_invalid_connect_params = + invalid_connect_params.length; + + function assertInvalidConnectParamsException(params) { + try { + browser.runtime.connect(...params); + } catch (e) { + detected_invalid_connect_params++; + browser.test.assertTrue( + e.toString().includes("Incorrect argument types for runtime.connect."), + "exception message is correct" + ); + } + } + for (let params of invalid_connect_params) { + assertInvalidConnectParamsException(params); + } + browser.test.assertEq( + expected_detected_invalid_connect_params, + detected_invalid_connect_params, + "all invalid runtime.connect params detected" + ); + + browser.runtime.connect(browser.runtime.id, { name: "check-results" }); +} + +let extensionData = { + background: backgroundScript, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, +}; + +add_task(async function test_backgroundRuntimeConnectParams() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("runtime.connect invalid params"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js new file mode 100644 index 0000000000..1c3180b1b6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_sub_windows.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindow() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("background script executed"); + + browser.test.sendMessage("background-script-load"); + + let img = document.createElement("img"); + img.src = + "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"; + document.body.appendChild(img); + + img.onload = () => { + browser.test.log("image loaded"); + + let iframe = document.createElement("iframe"); + iframe.src = "about:blank?1"; + + iframe.onload = () => { + browser.test.log("iframe loaded"); + setTimeout(() => { + browser.test.notifyPass("background sub-window test done"); + }, 0); + }; + document.body.appendChild(iframe); + }; + }, + }); + + let loadCount = 0; + extension.onMessage("background-script-load", () => { + loadCount++; + }); + + await extension.startup(); + + await extension.awaitFinish("background sub-window test done"); + + equal(loadCount, 1, "background script loaded only once"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js new file mode 100644 index 0000000000..013a68726c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_teardown.js @@ -0,0 +1,99 @@ +"use strict"; + +add_task(async function test_background_reload_and_unload() { + let events = []; + { + let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("reload-background", msg); + location.reload(); + }); + browser.test.sendMessage("background-url", location.href); + }, + }); + + await extension.startup(); + let backgroundUrl = await extension.awaitMessage("background-url"); + + let contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after loading an extension" + ); + equal(contextEvents[0].eventType, "load"); + equal( + contextEvents[0].url, + backgroundUrl, + "The ExtensionContext should be the background page" + ); + + extension.sendMessage("reload-background"); + await extension.awaitMessage("background-url"); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 2, + "ExtensionContext state changes after reloading the background page" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext of background page" + ); + equal( + contextEvents[0].url, + backgroundUrl, + "ExtensionContext URL = background" + ); + equal( + contextEvents[1].eventType, + "load", + "Create new ExtensionContext for background page" + ); + equal( + contextEvents[1].url, + backgroundUrl, + "ExtensionContext URL = background" + ); + + await extension.unload(); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after unloading the extension" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext for background page after extension unloads" + ); + equal( + contextEvents[0].url, + backgroundUrl, + "ExtensionContext URL = background" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js new file mode 100644 index 0000000000..8ca76ea3c2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_telemetry.js @@ -0,0 +1,104 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_BACKGROUND_PAGE_LOAD_MS"; +const HISTOGRAM_KEYED = "WEBEXT_BACKGROUND_PAGE_LOAD_MS_BY_ADDONID"; + +add_task(async function test_telemetry() { + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("loaded"); + }, + }); + + clearHistograms(); + + assertHistogramEmpty(HISTOGRAM); + assertKeyedHistogramEmpty(HISTOGRAM_KEYED); + + await extension1.startup(); + await extension1.awaitMessage("loaded"); + + const processSnapshot = snapshot => { + return snapshot.sum > 0; + }; + + const processKeyedSnapshot = snapshot => { + let res = {}; + for (let key of Object.keys(snapshot)) { + res[key] = snapshot[key].sum > 0; + } + return res; + }; + + assertHistogramSnapshot( + HISTOGRAM, + { processSnapshot, expectedValue: true }, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + }, + }, + `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}` + ); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = Services.telemetry.getKeyedHistogramById( + HISTOGRAM_KEYED + ); + let histogramSum = histogram.snapshot().sum; + let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum; + + await extension2.startup(); + await extension2.awaitMessage("loaded"); + + assertHistogramSnapshot( + HISTOGRAM, + { + processSnapshot: snapshot => snapshot.sum > histogramSum, + expectedValue: true, + }, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + [extension2.extension.id]: true, + }, + }, + `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}` + ); + + equal( + histogramKeyed.snapshot()[extension1.extension.id].sum, + histogramSumExt1, + `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}` + ); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js new file mode 100644 index 0000000000..fb2ca27482 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_background_window_properties.js @@ -0,0 +1,41 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testBackgroundWindowProperties() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let expectedValues = { + screenX: 0, + screenY: 0, + outerWidth: 0, + outerHeight: 0, + }; + + for (let k in window) { + try { + if (k in expectedValues) { + browser.test.assertEq( + expectedValues[k], + window[k], + `should return the expected value for window property: ${k}` + ); + } else { + void window[k]; + } + } catch (e) { + browser.test.assertEq( + null, + e, + `unexpected exception accessing window property: ${k}` + ); + } + } + + browser.test.notifyPass("background.testWindowProperties.done"); + }, + }); + await extension.startup(); + await extension.awaitFinish("background.testWindowProperties.done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js new file mode 100644 index 0000000000..c066147268 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_brokenlinks.js @@ -0,0 +1,54 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* + * This test extension has a background script 'missing.js' that is missing + * from the XPI. Such an extension should install/uninstall cleanly without + * causing timeouts. + */ +add_task(async function testXPIMissingBackGroundScript() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["missing.js"], + }, + }, + }); + + await extension.startup(); + await extension.unload(); + ok(true, "load/unload completed without timing out"); +}); + +/* + * This test extension includes a page with a missing script. The + * extension should install/uninstall cleanly without causing hangs. + */ +add_task(async function testXPIMissingPageScript() { + async function pageScript() { + browser.test.sendMessage("pageReady"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + files: { + "page.html": `<html><head> + <script src="missing.js"></script> + <script src="page.js"></script> + </head></html>`, + "page.js": pageScript, + }, + }); + + await extension.startup(); + let url = await extension.awaitMessage("ready"); + let contentPage = await ExtensionTestUtils.loadContentPage(url); + await extension.awaitMessage("pageReady"); + await extension.unload(); + await contentPage.close(); + + ok(true, "load/unload completed without timing out"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js new file mode 100644 index 0000000000..ed4eb8a664 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings.js @@ -0,0 +1,454 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +// The test extension uses an insecure update url. +Services.prefs.setBoolPref("extensions.checkUpdateSecurity", false); + +const SETTINGS_ID = "test_settings_staged_restart_webext@tests.mozilla.org"; + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_browser_settings() { + const PERM_DENY_ACTION = Services.perms.DENY_ACTION; + const PERM_UNKNOWN_ACTION = Services.perms.UNKNOWN_ACTION; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + "dom.popup_allowed_events": Preferences.get("dom.popup_allowed_events"), + "image.animation_mode": "none", + "permissions.default.desktop-notification": PERM_UNKNOWN_ACTION, + "ui.context_menus.after_mouseup": false, + "browser.tabs.closeTabByDblclick": false, + "browser.tabs.loadBookmarksInTabs": false, + "browser.search.openintab": false, + "browser.tabs.insertRelatedAfterCurrent": true, + "browser.tabs.insertAfterCurrent": false, + "browser.display.document_color_use": 1, + "browser.display.use_document_fonts": 1, + "browser.zoom.full": true, + "browser.zoom.siteSpecific": true, + }; + + async function background() { + let listeners = new Set([]); + browser.test.onMessage.addListener(async (msg, apiName, value) => { + let apiObj = browser.browserSettings[apiName]; + // Don't add more than one listner per apiName. We leave the + // listener to ensure we do not get more calls than we expect. + if (!listeners.has(apiName)) { + apiObj.onChange.addListener(details => { + browser.test.sendMessage("onChange", { + details, + setting: apiName, + }); + }); + listeners.add(apiName); + } + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + browser.test.sendMessage("settingData", await apiObj.get({})); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + browser.test.sendMessage("no-op set"); + } + }); + } + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + useAddonManager: "temporary", + }); + + await promiseStartupManager(); + await extension.startup(); + + async function testSetting(setting, value, expected, expectedValue = value) { + extension.sendMessage("set", setting, value); + let data = await extension.awaitMessage("settingData"); + let dataChange = await extension.awaitMessage("onChange"); + equal(setting, dataChange.setting, "onChange fired"); + equal( + data.value, + dataChange.details.value, + "onChange fired with correct value" + ); + deepEqual( + data.value, + expectedValue, + `The ${setting} setting has the expected value.` + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + `The ${setting} setting has the expected levelOfControl.` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + async function testNoOpSetting(setting, value, expected) { + extension.sendMessage("setNoOp", setting, value); + await extension.awaitMessage("no-op set"); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + await testSetting("cacheEnabled", false, { + "browser.cache.disk.enable": false, + "browser.cache.memory.enable": false, + }); + await testSetting("cacheEnabled", true, { + "browser.cache.disk.enable": true, + "browser.cache.memory.enable": true, + }); + + await testSetting("allowPopupsForUserEvents", false, { + "dom.popup_allowed_events": "", + }); + await testSetting("allowPopupsForUserEvents", true, { + "dom.popup_allowed_events": PREFS["dom.popup_allowed_events"], + }); + + for (let value of ["normal", "none", "once"]) { + await testSetting("imageAnimationBehavior", value, { + "image.animation_mode": value, + }); + } + + await testSetting("webNotificationsDisabled", true, { + "permissions.default.desktop-notification": PERM_DENY_ACTION, + }); + await testSetting("webNotificationsDisabled", false, { + // This pref is not defaulted on Android. + "permissions.default.desktop-notification": + AppConstants.MOZ_BUILD_APP !== "browser" + ? undefined + : PERM_UNKNOWN_ACTION, + }); + + // This setting is a no-op on Android. + if (AppConstants.platform === "android") { + await testNoOpSetting("contextMenuShowEvent", "mouseup", { + "ui.context_menus.after_mouseup": false, + }); + } else { + await testSetting("contextMenuShowEvent", "mouseup", { + "ui.context_menus.after_mouseup": true, + }); + } + + // "mousedown" is also a no-op on Windows. + if (["android", "win"].includes(AppConstants.platform)) { + await testNoOpSetting("contextMenuShowEvent", "mousedown", { + "ui.context_menus.after_mouseup": AppConstants.platform === "win", + }); + } else { + await testSetting("contextMenuShowEvent", "mousedown", { + "ui.context_menus.after_mouseup": false, + }); + } + + if (AppConstants.platform !== "android") { + await testSetting("closeTabsByDoubleClick", true, { + "browser.tabs.closeTabByDblclick": true, + }); + await testSetting("closeTabsByDoubleClick", false, { + "browser.tabs.closeTabByDblclick": false, + }); + } + + await testSetting("ftpProtocolEnabled", false, { + "network.ftp.enabled": false, + }); + await testSetting("ftpProtocolEnabled", true, { + "network.ftp.enabled": true, + }); + + await testSetting("newTabPosition", "afterCurrent", { + "browser.tabs.insertRelatedAfterCurrent": false, + "browser.tabs.insertAfterCurrent": true, + }); + await testSetting("newTabPosition", "atEnd", { + "browser.tabs.insertRelatedAfterCurrent": false, + "browser.tabs.insertAfterCurrent": false, + }); + await testSetting("newTabPosition", "relatedAfterCurrent", { + "browser.tabs.insertRelatedAfterCurrent": true, + "browser.tabs.insertAfterCurrent": false, + }); + + await testSetting("openBookmarksInNewTabs", true, { + "browser.tabs.loadBookmarksInTabs": true, + }); + await testSetting("openBookmarksInNewTabs", false, { + "browser.tabs.loadBookmarksInTabs": false, + }); + + await testSetting("openSearchResultsInNewTabs", true, { + "browser.search.openintab": true, + }); + await testSetting("openSearchResultsInNewTabs", false, { + "browser.search.openintab": false, + }); + + await testSetting("openUrlbarResultsInNewTabs", true, { + "browser.urlbar.openintab": true, + }); + await testSetting("openUrlbarResultsInNewTabs", false, { + "browser.urlbar.openintab": false, + }); + + await testSetting("overrideDocumentColors", "high-contrast-only", { + "browser.display.document_color_use": 0, + }); + await testSetting("overrideDocumentColors", "never", { + "browser.display.document_color_use": 1, + }); + await testSetting("overrideDocumentColors", "always", { + "browser.display.document_color_use": 2, + }); + + await testSetting("useDocumentFonts", false, { + "browser.display.use_document_fonts": 0, + }); + await testSetting("useDocumentFonts", true, { + "browser.display.use_document_fonts": 1, + }); + + await testSetting("zoomFullPage", true, { + "browser.zoom.full": true, + }); + await testSetting("zoomFullPage", false, { + "browser.zoom.full": false, + }); + + await testSetting("zoomSiteSpecific", true, { + "browser.zoom.siteSpecific": true, + }); + await testSetting("zoomSiteSpecific", false, { + "browser.zoom.siteSpecific": false, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_bad_value() { + async function background() { + await browser.test.assertRejects( + browser.browserSettings.contextMenuShowEvent.set({ value: "bad" }), + /bad is not a valid value for contextMenuShowEvent/, + "contextMenuShowEvent.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideDocumentColors.set({ value: 2 }), + /2 is not a valid value for overrideDocumentColors/, + "overrideDocumentColors.set rejects with an invalid value." + ); + + await browser.test.assertRejects( + browser.browserSettings.overrideDocumentColors.set({ value: "bad" }), + /bad is not a valid value for overrideDocumentColors/, + "overrideDocumentColors.set rejects with an invalid value." + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_bad_value_android() { + if (AppConstants.platform !== "android") { + return; + } + + async function background() { + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.set({ value: true }), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.set rejects on Android." + ); + + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.get({}), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.get rejects on Android." + ); + + await browser.test.assertRejects( + browser.browserSettings.closeTabsByDoubleClick.clear({}), + /android is not a supported platform for the closeTabsByDoubleClick setting/, + "closeTabsByDoubleClick.clear rejects on Android." + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Verifies settings remain after a staged update on restart. +add_task(async function delay_updates_settings_after_restart() { + let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] }); + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + "test_settings_staged_restart_webext@tests.mozilla.org": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart_v2.xpi", + }, + ], + }, + }, + }); + const update_xpi = AddonTestUtils.createTempXPIFile({ + "manifest.json": { + manifest_version: 2, + name: "Delay Upgrade", + version: "2.0", + applications: { + gecko: { id: SETTINGS_ID }, + }, + permissions: ["browserSettings"], + }, + }); + server.registerFile( + `/addons/test_settings_staged_restart_v2.xpi`, + update_xpi + ); + + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: SETTINGS_ID, + update_url: `http://example.com/test_update.json`, + }, + }, + permissions: ["browserSettings"], + }, + background() { + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details) { + await browser.browserSettings.webNotificationsDisabled.set({ + value: true, + }); + if (details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.notifyPass("delay"); + } + } else { + browser.test.fail("no details object passed"); + } + }); + browser.test.sendMessage("ready"); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + let prefname = "permissions.default.desktop-notification"; + let val = Services.prefs.getIntPref(prefname); + Assert.notEqual(val, 2, "webNotificationsDisabled pref not set"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal(install.state, AddonManager.STATE_POSTPONED); + await extension.awaitFinish("delay"); + + // restarting allows upgrade to proceed + await AddonTestUtils.promiseRestartManager(); + + await extension.awaitStartup(); + + // If an update is not handled correctly we would fail here. Bug 1639705. + val = Services.prefs.getIntPref(prefname); + Assert.equal(val, 2, "webNotificationsDisabled pref set"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + + val = Services.prefs.getIntPref(prefname); + Assert.notEqual(val, 2, "webNotificationsDisabled pref not set"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js new file mode 100644 index 0000000000..8d1d16c743 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browserSettings_homepage.js @@ -0,0 +1,36 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_homepage_get_without_set() { + async function background() { + let homepage = await browser.browserSettings.homepageOverride.get({}); + browser.test.sendMessage("homepage", homepage); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browserSettings"], + }, + }); + + let defaultHomepage = Services.prefs.getStringPref( + "browser.startup.homepage" + ); + + await extension.startup(); + let homepage = await extension.awaitMessage("homepage"); + equal( + homepage.value, + defaultHomepage, + "The homepageOverride setting has the expected value." + ); + equal( + homepage.levelOfControl, + "not_controllable", + "The homepageOverride setting has the expected levelOfControl." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js new file mode 100644 index 0000000000..1df5e60478 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testInvalidArguments() { + async function background() { + const UNSUPPORTED_DATA_TYPES = ["appcache", "fileSystems", "webSQL"]; + + await browser.test.assertRejects( + browser.browsingData.remove( + { originTypes: { protectedWeb: true } }, + { cookies: true } + ), + "Firefox does not support protectedWeb or extension as originTypes.", + "Expected error received when using protectedWeb originType." + ); + + await browser.test.assertRejects( + browser.browsingData.removeCookies({ originTypes: { extension: true } }), + "Firefox does not support protectedWeb or extension as originTypes.", + "Expected error received when using extension originType." + ); + + for (let dataType of UNSUPPORTED_DATA_TYPES) { + let dataTypes = {}; + dataTypes[dataType] = true; + browser.test.assertThrows( + () => browser.browsingData.remove({}, dataTypes), + /Type error for parameter dataToRemove/, + `Expected error received when using ${dataType} dataType.` + ); + } + + browser.test.notifyPass("invalidArguments"); + } + + let extensionData = { + background: background, + manifest: { + permissions: ["browsingData"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("invalidArguments"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js new file mode 100644 index 0000000000..612f2dd0f3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cache.js @@ -0,0 +1,456 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +const { SiteDataTestUtils } = ChromeUtils.import( + "resource://testing-common/SiteDataTestUtils.jsm" +); + +const COOKIE = { + host: "example.com", + name: "test_cookie", + path: "/", +}; +const COOKIE_NET = { + host: "example.net", + name: "test_cookie", + path: "/", +}; +const COOKIE_ORG = { + host: "example.org", + name: "test_cookie", + path: "/", +}; +let since, oldCookie; + +function addCookie(cookie) { + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + "test", + false, + false, + false, + Date.now() / 1000 + 10000, + {}, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + ok( + Services.cookies.cookieExists(cookie.host, cookie.path, cookie.name, {}), + `Cookie ${cookie.name} was created.` + ); +} + +async function setUpCookies() { + Services.cookies.removeAll(); + + // Add a cookie which will end up with an older creationTime. + oldCookie = Object.assign({}, COOKIE, { name: Date.now() }); + addCookie(oldCookie); + await new Promise(resolve => setTimeout(resolve, 10)); + since = Date.now(); + await new Promise(resolve => setTimeout(resolve, 10)); + + // Add a cookie which will end up with a more recent creationTime. + addCookie(COOKIE); + + // Add cookies for different domains. + addCookie(COOKIE_NET); + addCookie(COOKIE_ORG); +} + +async function setUpCache() { + Services.cache2.clear(); + + // Add cache entries for different domains. + for (const domain of ["example.net", "example.org", "example.com"]) { + await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "disk"); + await SiteDataTestUtils.addCacheEntry(`http://${domain}/`, "memory"); + } +} + +function hasCacheEntry(domain) { + const disk = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "disk"); + const memory = SiteDataTestUtils.hasCacheEntry(`http://${domain}/`, "memory"); + + equal( + disk, + memory, + `For ${domain} either either both or neither caches need to exists.` + ); + return disk; +} + +add_task(async function testCache() { + function background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "removeCache") { + await browser.browsingData.removeCache({}); + } else { + await browser.browsingData.remove({}, { cache: true }); + } + browser.test.sendMessage("cacheRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + await setUpCache(); + + extension.sendMessage(method); + await extension.awaitMessage("cacheRemoved"); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + } + + await extension.startup(); + + await testRemovalMethod("removeCache"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); + +add_task(async function testCookies() { + // Above in setUpCookies we create an 'old' cookies, wait 10ms, then log a timestamp. + // Here we ask the browser to delete all cookies after the timestamp, with the intention + // that the 'old' cookie is not removed. The issue arises when the timer precision is + // low enough such that the timestamp that gets logged is the same as the 'old' cookie. + // We hardcode a precision value to ensure that there is time between the 'old' cookie + // and the timestamp generation. + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", true); + Services.prefs.setIntPref( + "privacy.resistFingerprinting.reduceTimerPrecision.microseconds", + 2000 + ); + + registerCleanupFunction(function() { + Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); + Services.prefs.clearUserPref( + "privacy.resistFingerprinting.reduceTimerPrecision.microseconds" + ); + }); + + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeCookies") { + await browser.browsingData.removeCookies(options); + } else { + await browser.browsingData.remove(options, { cookies: true }); + } + browser.test.sendMessage("cookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear cookies with a recent since value. + await setUpCookies(); + extension.sendMessage(method, { since }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cookies with an old since value. + await setUpCookies(); + addCookie(COOKIE); + extension.sendMessage(method, { since: since - 100000 }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cookies with no since value and valid originTypes. + await setUpCookies(); + extension.sendMessage(method, { + originTypes: { unprotectedWeb: true, protectedWeb: false }, + }); + await extension.awaitMessage("cookiesRemoved"); + + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + `Cookie ${oldCookie.name} was removed.` + ); + } + + await extension.startup(); + + await testRemovalMethod("removeCookies"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); + +add_task(async function testCacheAndCookies() { + function background() { + browser.test.onMessage.addListener(async options => { + await browser.browsingData.remove(options, { + cache: true, + cookies: true, + }); + browser.test.sendMessage("cacheAndCookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + + // Clear cache and cookies with a recent since value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ since }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Cache does not support |since| and deletes everything! + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + // Clear cache and cookies with an old since value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ since: since - 100000 }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + // Cache does not support |since| and deletes everything! + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + + // Clear cache and cookies with hostnames value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ + hostnames: ["example.net", "example.org", "unknown.com"], + }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was not removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was removed.` + ); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(hasCacheEntry("example.com"), "example.com cache was not removed"); + + // Clear cache and cookies with (empty) hostnames value. + await setUpCookies(); + await setUpCache(); + extension.sendMessage({ hostnames: [] }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was not removed.` + ); + ok( + Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was not removed.` + ); + ok( + Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was not removed.` + ); + + ok(hasCacheEntry("example.net"), "example.net cache was not removed"); + ok(hasCacheEntry("example.org"), "example.org cache was not removed"); + ok(hasCacheEntry("example.com"), "example.com cache was not removed"); + + // Clear cache and cookies with both hostnames and since values. + await setUpCache(); + await setUpCookies(); + extension.sendMessage({ hostnames: ["example.com"], since }); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + "Old cookie was not removed." + ); + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + "Recent cookie was removed." + ); + ok( + Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + "Cookie with different hostname was not removed" + ); + ok( + Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + "Cookie with different hostname was not removed" + ); + + ok(hasCacheEntry("example.net"), "example.net cache was not removed"); + ok(hasCacheEntry("example.org"), "example.org cache was not removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + // Clear cache and cookies with no since or hostnames value. + await setUpCache(); + await setUpCookies(); + extension.sendMessage({}); + await extension.awaitMessage("cacheAndCookiesRemoved"); + + ok( + !Services.cookies.cookieExists(COOKIE.host, COOKIE.path, COOKIE.name, {}), + `Cookie ${COOKIE.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + oldCookie.host, + oldCookie.path, + oldCookie.name, + {} + ), + `Cookie ${oldCookie.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_NET.host, + COOKIE_NET.path, + COOKIE_NET.name, + {} + ), + `Cookie ${COOKIE_NET.name} was removed.` + ); + ok( + !Services.cookies.cookieExists( + COOKIE_ORG.host, + COOKIE_ORG.path, + COOKIE_ORG.name, + {} + ), + `Cookie ${COOKIE_ORG.name} was removed.` + ); + + ok(!hasCacheEntry("example.net"), "example.net cache was removed"); + ok(!hasCacheEntry("example.org"), "example.org cache was removed"); + ok(!hasCacheEntry("example.com"), "example.com cache was removed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js new file mode 100644 index 0000000000..d3d066efd2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_browsingData_cookies_cookieStoreId.js @@ -0,0 +1,192 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +// "Normal" cookie +const COOKIE_NORMAL = { + host: "example.com", + name: "test_cookie", + path: "/", + originAttributes: {}, +}; +// Private browsing cookie +const COOKIE_PRIVATE = { + host: "example.net", + name: "test_cookie", + path: "/", + originAttributes: { + privateBrowsingId: 1, + }, +}; +// "firefox-container-1" cookie +const COOKIE_CONTAINER = { + host: "example.org", + name: "test_cookie", + path: "/", + originAttributes: { + userContextId: 1, + }, +}; + +function cookieExists(cookie) { + return Services.cookies.cookieExists( + cookie.host, + cookie.path, + cookie.name, + cookie.originAttributes + ); +} + +function addCookie(cookie) { + const THE_FUTURE = Date.now() + 5 * 60; + + Services.cookies.add( + cookie.host, + cookie.path, + cookie.name, + "test", + false, + false, + false, + THE_FUTURE, + cookie.originAttributes, + Ci.nsICookie.SAMESITE_NONE, + Ci.nsICookie.SCHEME_HTTPS + ); + + ok(cookieExists(cookie), `Cookie ${cookie.name} was created.`); +} + +async function setUpCookies() { + Services.cookies.removeAll(); + + addCookie(COOKIE_NORMAL); + addCookie(COOKIE_PRIVATE); + addCookie(COOKIE_CONTAINER); +} + +add_task(async function testCookies() { + Services.prefs.setBoolPref("privacy.userContext.enabled", true); + + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeCookies") { + await browser.browsingData.removeCookies(options); + } else { + await browser.browsingData.remove(options, { cookies: true }); + } + browser.test.sendMessage("cookiesRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear only "normal"/default cookies. + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-default" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(!cookieExists(COOKIE_NORMAL), "Normal cookie was removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear container cookie + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-container-1" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed"); + + // Clear private cookie + await setUpCookies(); + + extension.sendMessage(method, { cookieStoreId: "firefox-private" }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear container cookie with correct hostname + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-container-1", + hostnames: ["example.org"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(!cookieExists(COOKIE_CONTAINER), "Container cookie was removed"); + + // Clear container cookie with incorrect hostname; nothing is removed + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-container-1", + hostnames: ["example.com"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie with correct hostname + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-private", + hostnames: ["example.net"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie with incorrect hostname; nothing is removed + await setUpCookies(); + + extension.sendMessage(method, { + cookieStoreId: "firefox-private", + hostnames: ["example.com"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(cookieExists(COOKIE_PRIVATE), "Private cookie was not removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + + // Clear private cookie by hostname + await setUpCookies(); + + extension.sendMessage(method, { + hostnames: ["example.net"], + }); + await extension.awaitMessage("cookiesRemoved"); + + ok(cookieExists(COOKIE_NORMAL), "Normal cookie was not removed"); + ok(!cookieExists(COOKIE_PRIVATE), "Private cookie was removed"); + ok(cookieExists(COOKIE_CONTAINER), "Container cookie was not removed"); + } + + await extension.startup(); + + await testRemovalMethod("removeCookies"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js new file mode 100644 index 0000000000..45c6a122fd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal.js @@ -0,0 +1,109 @@ +"use strict"; + +/** + * This duplicates the test from netwerk/test/unit/test_captive_portal_service.js + * however using an extension to gather the captive portal information. + */ + +const PREF_CAPTIVE_ENABLED = "network.captive-portal-service.enabled"; +const PREF_CAPTIVE_TESTMODE = "network.captive-portal-service.testMode"; +const PREF_CAPTIVE_MINTIME = "network.captive-portal-service.minInterval"; +const PREF_CAPTIVE_ENDPOINT = "captivedetect.canonicalURL"; +const PREF_DNS_NATIVE_IS_LOCALHOST = "network.dns.native-is-localhost"; + +const SUCCESS_STRING = "success\n"; +let cpResponse = SUCCESS_STRING; + +const httpserver = createHttpServer(); +httpserver.registerPathHandler("/captive.txt", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain"); + response.write(cpResponse); +}); + +registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_CAPTIVE_ENABLED); + Services.prefs.clearUserPref(PREF_CAPTIVE_TESTMODE); + Services.prefs.clearUserPref(PREF_CAPTIVE_ENDPOINT); + Services.prefs.clearUserPref(PREF_CAPTIVE_MINTIME); + Services.prefs.clearUserPref(PREF_DNS_NATIVE_IS_LOCALHOST); +}); + +add_task(function setup() { + Services.prefs.setCharPref( + PREF_CAPTIVE_ENDPOINT, + `http://localhost:${httpserver.identity.primaryPort}/captive.txt` + ); + Services.prefs.setBoolPref(PREF_CAPTIVE_TESTMODE, true); + Services.prefs.setIntPref(PREF_CAPTIVE_MINTIME, 0); + Services.prefs.setBoolPref(PREF_DNS_NATIVE_IS_LOCALHOST, true); +}); + +add_task(async function test_captivePortal_basic() { + let cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService( + Ci.nsICaptivePortalService + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["captivePortal"], + }, + isPrivileged: true, + async background() { + browser.captivePortal.onConnectivityAvailable.addListener(details => { + browser.test.log( + `onConnectivityAvailable received ${JSON.stringify(details)}` + ); + browser.test.sendMessage("connectivity", details); + }); + + browser.captivePortal.onStateChanged.addListener(details => { + browser.test.log(`onStateChanged received ${JSON.stringify(details)}`); + browser.test.sendMessage("state", details); + }); + + browser.test.onMessage.addListener(async msg => { + if (msg == "getstate") { + browser.test.sendMessage( + "getstate", + await browser.captivePortal.getState() + ); + } + }); + browser.test.assertEq( + "unknown", + await browser.captivePortal.getState(), + "initial state unknown" + ); + }, + }); + await extension.startup(); + + // The captive portal service is started by nsIOService when the pref becomes true, so we + // toggle the pref. We cannot set to false before the extension loads above. + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, false); + Services.prefs.setBoolPref(PREF_CAPTIVE_ENABLED, true); + + let details = await extension.awaitMessage("connectivity"); + equal(details.status, "clear", "initial connectivity"); + extension.sendMessage("getstate"); + details = await extension.awaitMessage("getstate"); + equal(details, "not_captive", "initial state"); + + info("REFRESH to other"); + cpResponse = "other"; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("state"); + equal(details.state, "locked_portal", "state in portal"); + + info("REFRESH to success"); + cpResponse = SUCCESS_STRING; + cps.recheckCaptivePortal(); + details = await extension.awaitMessage("connectivity"); + equal(details.status, "captive", "final connectivity"); + + details = await extension.awaitMessage("state"); + equal(details.state, "unlocked_portal", "state after unlocking portal"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js new file mode 100644 index 0000000000..7bd83b0572 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_captivePortal_url.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_url_get_without_set() { + async function background() { + browser.captivePortal.canonicalURL.onChange.addListener(details => { + browser.test.sendMessage("url", details); + }); + let url = await browser.captivePortal.canonicalURL.get({}); + browser.test.sendMessage("url", url); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["captivePortal"], + }, + }); + + let defaultURL = Services.prefs.getStringPref("captivedetect.canonicalURL"); + + await extension.startup(); + let url = await extension.awaitMessage("url"); + equal( + url.value, + defaultURL, + "The canonicalURL setting has the expected value." + ); + equal( + url.levelOfControl, + "not_controllable", + "The canonicalURL setting has the expected levelOfControl." + ); + + Services.prefs.setStringPref( + "captivedetect.canonicalURL", + "http://example.com" + ); + url = await extension.awaitMessage("url"); + equal( + url.value, + "http://example.com", + "The canonicalURL setting has the expected value." + ); + equal( + url.levelOfControl, + "not_controllable", + "The canonicalURL setting has the expected levelOfControl." + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js new file mode 100644 index 0000000000..71174716fd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentScripts_register.js @@ -0,0 +1,591 @@ +"use strict"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function check_applied_styles() { + const urlElStyle = getComputedStyle( + document.querySelector("#registered-extension-url-style") + ); + const blobElStyle = getComputedStyle( + document.querySelector("#registered-extension-text-style") + ); + + browser.test.sendMessage("registered-styles-results", { + registeredExtensionUrlStyleBG: urlElStyle["background-color"], + registeredExtensionBlobStyleBG: blobElStyle["background-color"], + }); +} + +add_task(async function test_contentscripts_register_css() { + async function background() { + let cssCode = ` + #registered-extension-text-style { + background-color: blue; + } + `; + + const matches = ["http://localhost/*/file_sample_registered_styles.html"]; + + browser.test.assertThrows( + () => { + browser.contentScripts.register({ + matches, + unknownParam: "unexpected property", + }); + }, + /Unexpected property "unknownParam"/, + "contentScripts.register throws on unexpected properties" + ); + + let fileScript = await browser.contentScripts.register({ + css: [{ file: "registered_ext_style.css" }], + matches, + runAt: "document_start", + }); + + let textScript = await browser.contentScripts.register({ + css: [{ code: cssCode }], + matches, + runAt: "document_start", + }); + + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "unregister-text": + await textScript.unregister().catch(err => { + browser.test.fail( + `Unexpected exception while unregistering text style: ${err}` + ); + }); + + await browser.test.assertRejects( + textScript.unregister(), + /Content script already unregistered/, + "Got the expected rejection on calling script.unregister() multiple times" + ); + + browser.test.sendMessage("unregister-text:done"); + break; + case "unregister-file": + await fileScript.unregister().catch(err => { + browser.test.fail( + `Unexpected exception while unregistering url style: ${err}` + ); + }); + + await browser.test.assertRejects( + fileScript.unregister(), + /Content script already unregistered/, + "Got the expected rejection on calling script.unregister() multiple times" + ); + + browser.test.sendMessage("unregister-file:done"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("background_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "http://localhost/*/file_sample_registered_styles.html", + "<all_urls>", + ], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + + files: { + "check_applied_styles.js": check_applied_styles, + "registered_ext_style.css": ` + #registered-extension-url-style { + background-color: red; + } + `, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background_ready"); + + // Ensure that a content page running in a content process and which has been + // started after the content scripts has been registered, it still receives + // and registers the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionUrlStyleBG, + "rgb(255, 0, 0)", + "The expected style has been applied from the registered extension url style" + ); + equal( + registeredStylesResults.registeredExtensionBlobStyleBG, + "rgb(0, 0, 255)", + "The expected style has been applied from the registered extension blob style" + ); + + extension.sendMessage("unregister-file"); + await extension.awaitMessage("unregister-file:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredURLStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredURLStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + equal( + unregisteredURLStylesResults.registeredExtensionBlobStyleBG, + "rgb(0, 0, 255)", + "The expected style has been applied from the registered extension blob style" + ); + + extension.sendMessage("unregister-text"); + await extension.awaitMessage("unregister-text:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredBlobStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredBlobStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + equal( + unregisteredBlobStylesResults.registeredExtensionBlobStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension blob style has been unregistered" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_contentscripts_unregister_on_context_unload() { + async function background() { + const frame = document.createElement("iframe"); + frame.setAttribute("src", "/background-frame.html"); + + document.body.appendChild(frame); + + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "unload-frame": + frame.remove(); + browser.test.sendMessage("unload-frame:done"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("background_ready"); + } + + async function background_frame() { + await browser.contentScripts.register({ + css: [{ file: "registered_ext_style.css" }], + matches: ["http://localhost/*/file_sample_registered_styles.html"], + runAt: "document_start", + }); + + browser.test.sendMessage("background_frame_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample_registered_styles.html"], + content_scripts: [ + { + matches: ["http://localhost/*/file_sample_registered_styles.html"], + run_at: "document_idle", + js: ["check_applied_styles.js"], + }, + ], + }, + background, + + files: { + "background-frame.html": `<!DOCTYPE html> + <html> + <head> + <script src="background-frame.js"></script> + </head> + <body> + </body> + </html> + `, + "background-frame.js": background_frame, + "check_applied_styles.js": check_applied_styles, + "registered_ext_style.css": ` + #registered-extension-url-style { + background-color: red; + } + `, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background_ready"); + + // Wait the background frame to have been loaded and its script + // executed. + await extension.awaitMessage("background_frame_ready"); + + // Ensure that a content page running in a content process and which has been + // started after the content scripts has been registered, it still receives + // and registers the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const registeredStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + registeredStylesResults.registeredExtensionUrlStyleBG, + "rgb(255, 0, 0)", + "The expected style has been applied from the registered extension url style" + ); + + extension.sendMessage("unload-frame"); + await extension.awaitMessage("unload-frame:done"); + + await contentPage.loadURL(`${BASE_URL}/file_sample_registered_styles.html`); + + const unregisteredURLStylesResults = await extension.awaitMessage( + "registered-styles-results" + ); + + equal( + unregisteredURLStylesResults.registeredExtensionUrlStyleBG, + "rgba(0, 0, 0, 0)", + "The expected style has been applied once extension url style has been unregistered" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_contentscripts_register_js() { + async function background() { + browser.runtime.onMessage.addListener( + ([msg, expectedStates, readyState], sender) => { + if (msg == "chrome-namespace-ok") { + browser.test.sendMessage(msg); + return; + } + + browser.test.assertEq("script-run", msg, "message type is correct"); + browser.test.assertTrue( + expectedStates.includes(readyState), + `readyState "${readyState}" is one of [${expectedStates}]` + ); + browser.test.sendMessage("script-run-" + expectedStates[0]); + } + ); + + // Raise an exception when the content script cannot be registered + // because the extension has no permission to access the specified origin. + + await browser.test.assertRejects( + browser.contentScripts.register({ + matches: ["http://*/*"], + js: [ + { + code: + 'browser.test.fail("content script with wrong matches should not run")', + }, + ], + }), + /Permission denied to register a content script for/, + "The reject contains the expected error message" + ); + + // Register a content script from a JS code string. + + function textScriptCodeStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function textScriptCodeEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function textScriptCodeIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + // Register content scripts from both extension URLs and plain JS code strings. + + const content_scripts = [ + // Plain JS code strings. + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeStart})()` }], + runAt: "document_start", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeEnd})()` }], + runAt: "document_end", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ code: `(${textScriptCodeIdle})()` }], + runAt: "document_idle", + }, + // Extension URLs. + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_start.js" }], + runAt: "document_start", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_end.js" }], + runAt: "document_end", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script_idle.js" }], + runAt: "document_idle", + }, + { + matches: ["http://localhost/*/file_sample.html"], + js: [{ file: "content_script.js" }], + // "runAt" is not specified here to ensure that it defaults to document_idle when missing. + }, + ]; + + const expectedAPIs = ["unregister"]; + + for (const scriptOptions of content_scripts) { + const script = await browser.contentScripts.register(scriptOptions); + const actualAPIs = Object.keys(script); + + browser.test.assertEq( + JSON.stringify(expectedAPIs), + JSON.stringify(actualAPIs), + `Got a script API object for ${scriptOptions.js[0]}` + ); + } + + browser.test.sendMessage("background-ready"); + } + + function contentScriptStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function contentScriptEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function contentScriptIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + function contentScript() { + let manifest = browser.runtime.getManifest(); + void manifest.permissions; + browser.runtime.sendMessage(["chrome-namespace-ok"]); + } + + let extensionData = { + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + }, + background, + + files: { + "content_script_start.js": contentScriptStart, + "content_script_end.js": contentScriptEnd, + "content_script_idle.js": contentScriptIdle, + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let loadingCount = 0; + let interactiveCount = 0; + let completeCount = 0; + extension.onMessage("script-run-loading", () => { + loadingCount++; + }); + extension.onMessage("script-run-interactive", () => { + interactiveCount++; + }); + + let completePromise = new Promise(resolve => { + extension.onMessage("script-run-complete", () => { + completeCount++; + resolve(); + }); + }); + + let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok"); + + // Ensure that a content page running in a content process and which has been + // already loaded when the content scripts has been registered, it has received + // and registered the expected content scripts. + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await contentPage.loadURL(`${BASE_URL}/file_sample.html`); + + await Promise.all([completePromise, chromeNamespacePromise]); + + await contentPage.close(); + + // Expect two content scripts to run (one registered using an extension URL + // and one registered from plain JS code). + equal(loadingCount, 2, "document_start script ran exactly twice"); + equal(interactiveCount, 2, "document_end script ran exactly twice"); + equal(completeCount, 2, "document_idle script ran exactly twice"); + + await extension.unload(); +}); + +// Test that the contentScript.register options are correctly translated +// into the expected WebExtensionContentScript properties. +add_task(async function test_contentscripts_register_all_options() { + async function background() { + await browser.contentScripts.register({ + js: [{ file: "content_script.js" }], + css: [{ file: "content_style.css" }], + matches: ["http://localhost/*"], + excludeMatches: ["http://localhost/exclude/*"], + excludeGlobs: ["*_exclude.html"], + includeGlobs: ["*_include.html"], + allFrames: true, + matchAboutBlank: true, + runAt: "document_start", + }); + + browser.test.sendMessage("background-ready", window.location.origin); + } + + const extensionData = { + manifest: { + permissions: ["http://localhost/*"], + }, + background, + + files: { + "content_script.js": "", + "content_style.css": "", + }, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + const baseExtURL = await extension.awaitMessage("background-ready"); + + const policy = WebExtensionPolicy.getByID(extension.id); + + ok(policy, "Got the WebExtensionPolicy for the test extension"); + equal( + policy.contentScripts.length, + 1, + "Got the expected number of registered content scripts" + ); + + const script = policy.contentScripts[0]; + let { allFrames, cssPaths, jsPaths, matchAboutBlank, runAt } = script; + + deepEqual( + { + allFrames, + cssPaths, + jsPaths, + matchAboutBlank, + runAt, + }, + { + allFrames: true, + cssPaths: [`${baseExtURL}/content_style.css`], + jsPaths: [`${baseExtURL}/content_script.js`], + matchAboutBlank: true, + runAt: "document_start", + }, + "Got the expected content script properties" + ); + + ok( + script.matchesURI(Services.io.newURI("http://localhost/ok_include.html")), + "matched and include globs should match" + ); + ok( + !script.matchesURI( + Services.io.newURI("http://localhost/exclude/ok_include.html") + ), + "exclude matches should not match" + ); + ok( + !script.matchesURI(Services.io.newURI("http://localhost/ok_exclude.html")), + "exclude globs should not match" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js new file mode 100644 index 0000000000..47de723f0f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_content_security_policy.js @@ -0,0 +1,251 @@ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +server.registerPathHandler("/worker.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/javascript", false); + response.write("let x = true;"); +}); + +const baseCSP = []; +baseCSP[2] = { + "object-src": ["blob:", "filesystem:", "moz-extension:", "'self'"], + "script-src": [ + "'unsafe-eval'", + "'unsafe-inline'", + "blob:", + "filesystem:", + "http://localhost:*", + "http://127.0.0.1:*", + "https://*", + "moz-extension:", + "'self'", + ], +}; +baseCSP[3] = { + "object-src": ["'self'"], + "script-src": ["http://localhost:*", "http://127.0.0.1:*", "'self'"], + "worker-src": ["http://localhost:*", "http://127.0.0.1:*", "'self'"], +}; + +/** + * Tests that content security policies for an add-on are actually applied to * + * documents that belong to it. This tests both the base policies and add-on + * specific policies, and ensures that the parsed policies applied to the + * document's principal match what was specified in the policy string. + * + * @param {number} [manifest_version] + * @param {object} [customCSP] + */ +async function testPolicy(manifest_version = 2, customCSP = null) { + let baseURL; + + let addonCSP = { + "object-src": ["'self'"], + "script-src": ["'self'"], + }; + + let content_security_policy = null; + + if (customCSP) { + for (let key of Object.keys(customCSP)) { + addonCSP[key] = customCSP[key].split(/\s+/); + } + + content_security_policy = Object.keys(customCSP) + .map(key => `${key} ${customCSP[key]}`) + .join("; "); + } + + function checkSource(name, policy, expected) { + // fallback to script-src when comparing worker-src if policy does not include worker-src + let policySrc = + name != "worker-src" || policy[name] + ? policy[name] + : policy["script-src"]; + equal( + JSON.stringify(policySrc.sort()), + JSON.stringify(expected[name].sort()), + `Expected value for ${name}` + ); + } + + function checkCSP(csp, location) { + let policies = csp["csp-policies"]; + + info(`Base policy for ${location}`); + let base = baseCSP[manifest_version]; + + equal(policies[0]["report-only"], false, "Policy is not report-only"); + for (let key in base) { + checkSource(key, policies[0], base); + } + + info(`Add-on policy for ${location}`); + + equal(policies[1]["report-only"], false, "Policy is not report-only"); + for (let key in addonCSP) { + checkSource(key, policies[1], addonCSP); + } + } + + function background() { + browser.test.sendMessage( + "base-url", + browser.extension.getURL("").replace(/\/$/, "") + ); + + browser.test.sendMessage("background-csp", window.getCSP()); + } + + function tabScript() { + browser.test.sendMessage("tab-csp", window.getCSP()); + + const worker = new Worker("worker.js"); + worker.onmessage = event => { + browser.test.sendMessage("worker-csp", event.data); + }; + + worker.postMessage({}); + } + + function testWorker(port) { + this.onmessage = () => { + try { + // eslint-disable-next-line no-undef + importScripts(`http://127.0.0.1:${port}/worker.js`); + postMessage({ loaded: true }); + } catch (e) { + postMessage({ loaded: false }); + } + }; + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + + files: { + "tab.html": `<html><head><meta charset="utf-8"> + <script src="tab.js"></${"script"}></head></html>`, + + "tab.js": tabScript, + + "content.html": `<html><head><meta charset="utf-8"></head></html>`, + "worker.js": `(${testWorker})(${server.identity.primaryPort})`, + }, + + manifest: { + manifest_version, + content_security_policy, + + web_accessible_resources: ["content.html", "tab.html"], + }, + }); + + function frameScript() { + // eslint-disable-next-line mozilla/balanced-listeners + addEventListener( + "DOMWindowCreated", + event => { + let win = event.target.ownerGlobal; + function getCSP() { + let { cspJSON } = win.document; + return win.wrappedJSObject.JSON.parse(cspJSON); + } + Cu.exportFunction(getCSP, win, { defineAs: "getCSP" }); + }, + true + ); + } + let frameScriptURL = `data:,(${encodeURI(frameScript)}).call(this)`; + Services.mm.loadFrameScript(frameScriptURL, true, true); + + info(`Testing CSP for policy: ${content_security_policy}`); + + await extension.startup(); + + baseURL = await extension.awaitMessage("base-url"); + + let tabPage = await ExtensionTestUtils.loadContentPage( + `${baseURL}/tab.html`, + { extension } + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + let contentCSP = await contentPage.spawn( + `${baseURL}/content.html`, + async src => { + let doc = this.content.document; + + let frame = doc.createElement("iframe"); + frame.src = src; + doc.body.appendChild(frame); + + await new Promise(resolve => { + frame.onload = resolve; + }); + + return frame.contentWindow.wrappedJSObject.getCSP(); + } + ); + + let backgroundCSP = await extension.awaitMessage("background-csp"); + checkCSP(backgroundCSP, "background page"); + + let tabCSP = await extension.awaitMessage("tab-csp"); + checkCSP(tabCSP, "tab page"); + + checkCSP(contentCSP, "content frame"); + + let workerCSP = await extension.awaitMessage("worker-csp"); + // TODO BUG 1685627: This test should fail if localhost is not in the csp. + ok(workerCSP.loaded, "worker loaded"); + + await contentPage.close(); + await tabPage.close(); + + await extension.unload(); + + Services.mm.removeDelayedFrameScript(frameScriptURL); +} + +add_task(async function testCSP() { + await testPolicy(2, null); + + let hash = + "'sha256-NjZhMDQ1YjQ1MjEwMmM1OWQ4NDBlYzA5N2Q1OWQ5NDY3ZTEzYTNmMzRmNjQ5NGU1MzlmZmQzMmMxYmIzNWYxOCAgLQo='"; + + await testPolicy(2, { + "object-src": "'self' https://*.example.com", + "script-src": `'self' https://*.example.com 'unsafe-eval' ${hash}`, + }); + + await testPolicy(2, { + "object-src": "'none'", + "script-src": `'self'`, + }); + + await testPolicy(3, { + "object-src": "'self' http://localhost", + "script-src": `'self' http://localhost:123 ${hash}`, + "worker-src": `'self' http://127.0.0.1:*`, + }); + + await testPolicy(3, { + "object-src": "'none'", + "script-src": `'self'`, + "worker-src": `'self'`, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js new file mode 100644 index 0000000000..1d130798f6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript.js @@ -0,0 +1,266 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_contentscript_runAt() { + function background() { + browser.runtime.onMessage.addListener( + ([msg, expectedStates, readyState], sender) => { + if (msg == "chrome-namespace-ok") { + browser.test.sendMessage(msg); + return; + } + + browser.test.assertEq("script-run", msg, "message type is correct"); + browser.test.assertTrue( + expectedStates.includes(readyState), + `readyState "${readyState}" is one of [${expectedStates}]` + ); + browser.test.sendMessage("script-run-" + expectedStates[0]); + } + ); + } + + function contentScriptStart() { + browser.runtime.sendMessage([ + "script-run", + ["loading"], + document.readyState, + ]); + } + function contentScriptEnd() { + browser.runtime.sendMessage([ + "script-run", + ["interactive", "complete"], + document.readyState, + ]); + } + function contentScriptIdle() { + browser.runtime.sendMessage([ + "script-run", + ["complete"], + document.readyState, + ]); + } + + function contentScript() { + let manifest = browser.runtime.getManifest(); + void manifest.applications.gecko.id; + browser.runtime.sendMessage(["chrome-namespace-ok"]); + } + + let extensionData = { + manifest: { + applications: { gecko: { id: "contentscript@tests.mozilla.org" } }, + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_start.js"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_end.js"], + run_at: "document_end", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_idle.js"], + run_at: "document_idle", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_idle.js"], + // Test default `run_at`. + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + background, + + files: { + "content_script_start.js": contentScriptStart, + "content_script_end.js": contentScriptEnd, + "content_script_idle.js": contentScriptIdle, + "content_script.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let loadingCount = 0; + let interactiveCount = 0; + let completeCount = 0; + extension.onMessage("script-run-loading", () => { + loadingCount++; + }); + extension.onMessage("script-run-interactive", () => { + interactiveCount++; + }); + + let completePromise = new Promise(resolve => { + extension.onMessage("script-run-complete", () => { + completeCount++; + if (completeCount > 1) { + resolve(); + } + }); + }); + + let chromeNamespacePromise = extension.awaitMessage("chrome-namespace-ok"); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await Promise.all([completePromise, chromeNamespacePromise]); + + await contentPage.close(); + + equal(loadingCount, 1, "document_start script ran exactly once"); + equal(interactiveCount, 1, "document_end script ran exactly once"); + equal(completeCount, 2, "document_idle script ran exactly twice"); + + await extension.unload(); +}); + +add_task(async function test_contentscript_window_open() { + if (AppConstants.DEBUG && ExtensionTestUtils.remoteContentScripts) { + return; + } + + let script = async () => { + /* globals x */ + browser.test.assertEq(1, x, "Should only run once"); + + if (top !== window) { + // Wait for our parent page to load, then set a timeout to wait for the + // document.open call, so we make sure to not tear down the extension + // until after we've done the document.open. + await new Promise(resolve => { + top.addEventListener("load", () => setTimeout(resolve, 0), { + once: true, + }); + }); + } + + browser.test.sendMessage("content-script", [location.href, top === window]); + }; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "contentscript@tests.mozilla.org" } }, + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["content_script.js"], + run_at: "document_start", + match_about_blank: true, + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": ` + var x = (x || 0) + 1; + (${script})(); + `, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_document_open.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let [pageURL, pageIsTop] = await extension.awaitMessage("content-script"); + + // Sometimes we get a content script load for the initial about:blank + // top level frame here, sometimes we don't. Either way is fine, as long as we + // don't get two loads into the same document.open() document. + if (pageURL === "about:blank") { + equal(pageIsTop, true); + [pageURL, pageIsTop] = await extension.awaitMessage("content-script"); + } + + Assert.deepEqual([pageURL, pageIsTop], [url, true]); + + let [frameURL, isTop] = await extension.awaitMessage("content-script"); + Assert.deepEqual([frameURL, isTop], [url, false]); + + await contentPage.close(); + await extension.unload(); +}); + +// This test verify that a cached script is still able to catch the document +// while it is still loading (when we do not block the document parsing as +// we do for a non cached script). +add_task(async function test_cached_contentscript_on_document_start() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_document_open.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js": ` + browser.test.sendMessage("content-script-loaded", { + url: window.location.href, + documentReadyState: document.readyState, + }); + `, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_document_open.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let msg = await extension.awaitMessage("content-script-loaded"); + Assert.deepEqual( + msg, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a non cached script" + ); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(url); + + let msgFromCached = await extension.awaitMessage("content-script-loaded"); + Assert.deepEqual( + msgFromCached, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a cached script" + ); + + await extension.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js new file mode 100644 index 0000000000..023cc3d2a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_about_blank_start.js @@ -0,0 +1,78 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/blank-iframe.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<iframe></iframe>"); +}); + +add_task(async function content_script_at_document_start() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["start.js"], + run_at: "document_start", + match_about_blank: true, + }, + ], + }, + + files: { + "start.js": function() { + browser.test.sendMessage("content-script-done"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + await extension.awaitMessage("content-script-done"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function content_style_at_document_start() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + css: ["start.css"], + run_at: "document_start", + match_about_blank: true, + }, + { + matches: ["<all_urls>"], + js: ["end.js"], + run_at: "document_end", + match_about_blank: true, + }, + ], + }, + + files: { + "start.css": "body { background: red; }", + "end.js": function() { + let style = window.getComputedStyle(document.body); + browser.test.assertEq( + "rgb(255, 0, 0)", + style.backgroundColor, + "document_start style should have been applied" + ); + browser.test.sendMessage("content-script-done"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + await extension.awaitMessage("content-script-done"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js new file mode 100644 index 0000000000..4e42181e71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_api_injection.js @@ -0,0 +1,65 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_api_injection() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + web_accessible_resources: ["content_script_iframe.html"], + }, + + files: { + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("content_script_iframe.html"); + document.body.appendChild(iframe); + }, + "content_script_iframe.js"() { + window.location = `http://example.com/data/file_privilege_escalation.html`; + }, + "content_script_iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script type="text/javascript" src="content_script_iframe.js"></script> + </head> + </html>`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let awaitConsole = new Promise(resolve => { + Services.console.registerListener(function listener(message) { + if (/WebExt Privilege Escalation/.test(message.message)) { + Services.console.unregisterListener(listener); + resolve(message); + } + }); + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let message = await awaitConsole; + ok( + message.message.includes( + "WebExt Privilege Escalation: typeof(browser) = undefined" + ), + "Document does not have `browser` APIs." + ); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js new file mode 100644 index 0000000000..cb9a07142d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_async_loading.js @@ -0,0 +1,79 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_async_loading() { + const adder = `(function add(a = 1) { this.count += a; })();\n`; + + const extension = { + manifest: { + content_scripts: [ + { + run_at: "document_start", + matches: ["http://example.com/dummy"], + js: ["first.js", "second.js"], + }, + { + run_at: "document_end", + matches: ["http://example.com/dummy"], + js: ["third.js"], + }, + ], + }, + files: { + "first.js": ` + this.count = 0; + ${adder.repeat(50000)}; // 2Mb + browser.test.assertEq(this.count, 50000, "A 50k line script"); + + this.order = (this.order || 0) + 1; + browser.test.sendMessage("first", this.order); + `, + "second.js": ` + this.order = (this.order || 0) + 1; + browser.test.sendMessage("second", this.order); + `, + "third.js": ` + this.order = (this.order || 0) + 1; + browser.test.sendMessage("third", this.order); + `, + }, + }; + + async function checkOrder(ext) { + const [first, second, third] = await Promise.all([ + ext.awaitMessage("first"), + ext.awaitMessage("second"), + ext.awaitMessage("third"), + ]); + + equal(first, 1, "first.js finished execution first."); + equal(second, 2, "second.js finished execution second."); + equal(third, 3, "third.js finished execution third."); + } + + info("Test pages observed while extension is running"); + const observed = ExtensionTestUtils.loadExtension(extension); + await observed.startup(); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await checkOrder(observed); + await observed.unload(); + + info("Test pages already existing on extension startup"); + const existing = ExtensionTestUtils.loadExtension(extension); + + await existing.startup(); + await checkOrder(existing); + await existing.unload(); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js new file mode 100644 index 0000000000..4ac22dc700 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_canvas_tainting.js @@ -0,0 +1,128 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["green.example.com", "red.example.com"], +}); + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/pixel.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write(`<!DOCTYPE html> + <script> + function readByWeb() { + let ctx = document.querySelector("canvas").getContext("2d"); + let {data} = ctx.getImageData(0, 0, 1, 1); + return data.slice(0, 3).join(); + } + </script> + `); +}); + +add_task(async function test_contentscript_canvas_tainting() { + async function contentScript() { + let canvas = document.createElement("canvas"); + let ctx = canvas.getContext("2d"); + document.body.appendChild(canvas); + + function draw(url) { + return new Promise(resolve => { + let img = document.createElement("img"); + img.onload = () => { + ctx.drawImage(img, 0, 0, 1, 1); + resolve(); + }; + img.src = url; + }); + } + + function readByExt() { + let { data } = ctx.getImageData(0, 0, 1, 1); + return data.slice(0, 3).join(); + } + + let readByWeb = window.wrappedJSObject.readByWeb; + + // Test reading after drawing an image from the same origin as the web page. + await draw("http://green.example.com/data/pixel_green.gif"); + browser.test.assertEq( + readByWeb(), + "0,255,0", + "Content can read same-origin image" + ); + browser.test.assertEq( + readByExt(), + "0,255,0", + "Extension can read same-origin image" + ); + + // Test reading after drawing a blue pixel data URI from extension content script. + await draw( + "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs=" + ); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Content can't read extension's image" + ); + browser.test.assertEq( + readByExt(), + "0,0,255", + "Extension can read its own image" + ); + + // Test after tainting the canvas with an image from a third party domain. + await draw("http://red.example.com/data/pixel_red.gif"); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Content can't read third party image" + ); + browser.test.assertThrows( + readByExt, + /operation is insecure/, + "Extension can't read fully tainted" + ); + + // Test canvas is still fully tainted after drawing extension's data: image again. + await draw( + "data:image/gif;base64,R0lGODlhAQABAIABAAAA/wAAACwAAAAAAQABAAACAkQBADs=" + ); + browser.test.assertThrows( + readByWeb, + /operation is insecure/, + "Canvas still fully tainted for content" + ); + browser.test.assertThrows( + readByExt, + /operation is insecure/, + "Canvas still fully tainted for extension" + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://green.example.com/pixel.html"], + js: ["cs.js"], + }, + ], + }, + files: { + "cs.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://green.example.com/pixel.html" + ); + await extension.awaitMessage("done"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js new file mode 100644 index 0000000000..2bb30f3c90 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context.js @@ -0,0 +1,348 @@ +"use strict"; + +/* eslint-disable mozilla/balanced-listeners */ + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +function loadExtension() { + function contentScript() { + browser.test.sendMessage("content-script-ready"); + + window.addEventListener( + "pagehide", + () => { + browser.test.sendMessage("content-script-hide"); + }, + true + ); + window.addEventListener("pageshow", () => { + browser.test.sendMessage("content-script-show"); + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy*"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); +} + +add_task(async function test_contentscript_context() { + let extension = loadExtension(); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitMessage("content-script-ready"); + await extension.awaitMessage("content-script-show"); + + // Get the content script context and check that it points to the correct window. + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + this.context = DocumentManager.getContext(extensionId, this.content); + + Assert.ok(this.context, "Got content script context"); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + // Navigate so that the content page is hidden in the bfcache. + + this.content.location = "http://example.org/dummy"; + }); + + await extension.awaitMessage("content-script-hide"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + }); + + await contentPage.close(); + await extension.awaitMessage("content-script-hide"); + await extension.unload(); +}); + +async function contentscript_context_incognito_not_allowed_test() { + async function background() { + await browser.contentScripts.register({ + js: [{ file: "registered_script.js" }], + matches: ["http://example.com/dummy"], + runAt: "document_start", + }); + + browser.test.sendMessage("background-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + permissions: ["http://example.com/*"], + }, + background, + files: { + "content_script.js": () => { + browser.test.notifyFail("content_script_loaded"); + }, + "registered_script.js": () => { + browser.test.notifyFail("registered_script_loaded"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + let context = DocumentManager.getContext(extensionId, this.content); + Assert.equal( + context, + null, + "Extension unable to use content_script in private browsing window" + ); + }); + + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_contentscript_context_incognito_not_allowed() { + return runWithPrefs( + [["extensions.allowPrivateBrowsingByDefault", false]], + contentscript_context_incognito_not_allowed_test + ); +}); + +add_task(async function test_contentscript_context_unload_while_in_bfcache() { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy?first" + ); + let extension = loadExtension(); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + // Get the content script context and check that it points to the correct window. + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + // Save context so we can verify that contentWindow is nulled after unload. + this.context = DocumentManager.getContext(extensionId, this.content); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + this.contextUnloadedPromise = new Promise(resolve => { + this.context.callOnClose({ close: resolve }); + }); + this.pageshownPromise = new Promise(resolve => { + this.content.addEventListener( + "pageshow", + () => { + // Yield to the event loop once more to ensure that all pageshow event + // handlers have been dispatched before fulfilling the promise. + let { setTimeout } = ChromeUtils.import( + "resource://gre/modules/Timer.jsm" + ); + setTimeout(resolve, 0); + }, + { once: true, mozSystemGroup: true } + ); + }); + + // Navigate so that the content page is hidden in the bfcache. + this.content.location = "http://example.org/dummy?second"; + }); + + await extension.awaitMessage("content-script-hide"); + + await extension.unload(); + await contentPage.spawn(null, async () => { + await this.contextUnloadedPromise; + Assert.equal(this.context.unloaded, true, "Context has been unloaded"); + + // Normally, when a page is not in the bfcache, context.contentWindow is + // not null when the callOnClose handler is invoked (this is checked by the + // previous subtest). + // Now wait a little bit and check again to ensure that the contentWindow + // property is not somehow restored. + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + + await this.pageshownPromise; + + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null after restore from bfcache" + ); + }); + + await contentPage.close(); +}); + +add_task(async function test_contentscript_context_valid_during_execution() { + // This test does the following: + // - Load page + // - Load extension; inject content script. + // - Navigate page; pagehide triggered. + // - Navigate back; pageshow triggered. + // - Close page; pagehide, unload triggered. + // At each of these last four events, the validity of the context is checked. + + function contentScript() { + browser.test.sendMessage("content-script-ready"); + window.wrappedJSObject.checkContextIsValid("Context is valid on execution"); + + window.addEventListener( + "pagehide", + () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on pagehide" + ); + browser.test.sendMessage("content-script-hide"); + }, + true + ); + window.addEventListener("pageshow", () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on pageshow" + ); + + // This unload listener is registered after pageshow, to ensure that the + // page can be stored in the bfcache at the previous pagehide. + window.addEventListener("unload", () => { + window.wrappedJSObject.checkContextIsValid( + "Context is valid on unload" + ); + browser.test.sendMessage("content-script-unload"); + }); + + browser.test.sendMessage("content-script-show"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy*"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy?first" + ); + await contentPage.spawn(extension.id, async extensionId => { + let context; + let checkContextIsValid = description => { + if (!context) { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + context = DocumentManager.getContext(extensionId, this.content); + } + Assert.equal( + context.contentWindow, + this.content, + `${description}: contentWindow` + ); + Assert.equal(context.active, true, `${description}: active`); + }; + Cu.exportFunction(checkContextIsValid, this.content, { + defineAs: "checkContextIsValid", + }); + }); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + await contentPage.spawn(extension.id, async extensionId => { + // Navigate so that the content page is frozen in the bfcache. + this.content.location = "http://example.org/dummy?second"; + }); + + await extension.awaitMessage("content-script-hide"); + await contentPage.spawn(null, async () => { + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + await contentPage.close(); + await extension.awaitMessage("content-script-hide"); + await extension.awaitMessage("content-script-unload"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js new file mode 100644 index 0000000000..1b705e0a53 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_context_isolation.js @@ -0,0 +1,160 @@ +"use strict"; + +/* globals exportFunction */ +/* eslint-disable mozilla/balanced-listeners */ + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +server.registerPathHandler("/bfcachetestpage", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html;charset=utf-8", false); + response.write(`<!DOCTYPE html> +<script> + window.addEventListener("pageshow", (event) => { + event.stopImmediatePropagation(); + if (window.browserTestSendMessage) { + browserTestSendMessage("content-script-show"); + } + }); + window.addEventListener("pagehide", (event) => { + event.stopImmediatePropagation(); + if (window.browserTestSendMessage) { + if (event.persisted) { + browserTestSendMessage("content-script-hide"); + } else { + browserTestSendMessage("content-script-unload"); + } + } + }, true); +</script>`); +}); + +add_task(async function test_contentscript_context_isolation() { + function contentScript() { + browser.test.sendMessage("content-script-ready"); + + exportFunction(browser.test.sendMessage, window, { + defineAs: "browserTestSendMessage", + }); + + window.addEventListener("pageshow", () => { + browser.test.fail( + "pageshow should have been suppressed by stopImmediatePropagation" + ); + }); + window.addEventListener( + "pagehide", + () => { + browser.test.fail( + "pagehide should have been suppressed by stopImmediatePropagation" + ); + }, + true + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/bfcachetestpage"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/bfcachetestpage" + ); + await extension.startup(); + await extension.awaitMessage("content-script-ready"); + + // Get the content script context and check that it points to the correct window. + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + this.context = DocumentManager.getContext(extensionId, this.content); + + Assert.ok(this.context, "Got content script context"); + + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + + // Navigate so that the content page is hidden in the bfcache. + + this.content.location = "http://example.org/dummy?noscripthere1"; + }); + + await extension.awaitMessage("content-script-hide"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.contentWindow, + null, + "Context's contentWindow property is null" + ); + Assert.ok(this.context.sandbox, "Context's sandbox exists"); + + // Navigate back so the content page is resurrected from the bfcache. + this.content.history.back(); + }); + + await extension.awaitMessage("content-script-show"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property is correct" + ); + Assert.ok(this.context.sandbox, "Context's sandbox exists before unload"); + + let contextUnloadedPromise = new Promise(resolve => { + this.context.callOnClose({ close: resolve }); + }); + + // Now add an "unload" event listener, which should prevent a page from entering the bfcache. + await new Promise(resolve => { + this.content.addEventListener("unload", () => { + Assert.equal( + this.context.contentWindow, + this.content, + "Context's contentWindow property should be non-null at unload" + ); + resolve(); + }); + this.content.location = "http://example.org/dummy?noscripthere2"; + }); + + await contextUnloadedPromise; + }); + + await extension.awaitMessage("content-script-unload"); + + await contentPage.spawn(null, async () => { + Assert.equal( + this.context.sandbox, + null, + "Context's sandbox has been destroyed after unload" + ); + }); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js new file mode 100644 index 0000000000..ff2622e4fb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_create_iframe.js @@ -0,0 +1,177 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_create_iframe() { + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + let { name, availableAPIs, manifest, testGetManifest } = msg; + let hasExtTabsAPI = availableAPIs.indexOf("tabs") > 0; + let hasExtWindowsAPI = availableAPIs.indexOf("windows") > 0; + + browser.test.assertFalse( + hasExtTabsAPI, + "the created iframe should not be able to use privileged APIs (tabs)" + ); + browser.test.assertFalse( + hasExtWindowsAPI, + "the created iframe should not be able to use privileged APIs (windows)" + ); + + let { + applications: { + gecko: { id: expectedManifestGeckoId }, + }, + } = chrome.runtime.getManifest(); + let { + applications: { + gecko: { id: actualManifestGeckoId }, + }, + } = manifest; + + browser.test.assertEq( + actualManifestGeckoId, + expectedManifestGeckoId, + "the add-on manifest should be accessible from the created iframe" + ); + + let { + applications: { + gecko: { id: testGetManifestGeckoId }, + }, + } = testGetManifest; + + browser.test.assertEq( + testGetManifestGeckoId, + expectedManifestGeckoId, + "GET_MANIFEST() returns manifest data before extension unload" + ); + + browser.test.sendMessage(name); + }); + } + + function contentScriptIframe() { + window.GET_MANIFEST = browser.runtime.getManifest.bind(null); + + window.testGetManifestException = () => { + try { + window.GET_MANIFEST(); + } catch (exception) { + return String(exception); + } + }; + + let testGetManifest = window.GET_MANIFEST(); + + let manifest = browser.runtime.getManifest(); + let availableAPIs = Object.keys(browser).filter(key => browser[key]); + + browser.runtime.sendMessage({ + name: "content-script-iframe-loaded", + availableAPIs, + manifest, + testGetManifest, + }); + } + + const ID = "contentscript@tests.mozilla.org"; + let extensionData = { + manifest: { + applications: { gecko: { id: ID } }, + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + web_accessible_resources: ["content_script_iframe.html"], + }, + + background, + + files: { + "content_script.js"() { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("content_script_iframe.html"); + document.body.appendChild(iframe); + }, + "content_script_iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script type="text/javascript" src="content_script_iframe.js"></script> + </head> + </html>`, + "content_script_iframe.js": contentScriptIframe, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitMessage("content-script-iframe-loaded"); + + info("testing APIs availability once the extension is unloaded..."); + + await contentPage.spawn(null, () => { + this.iframeWindow = this.content[0]; + + Assert.ok(this.iframeWindow, "content script enabled iframe found"); + Assert.ok( + /content_script_iframe\.html$/.test(this.iframeWindow.location), + "the found iframe has the expected URL" + ); + }); + + await extension.unload(); + + info( + "test content script APIs not accessible from the frame once the extension is unloaded" + ); + + await contentPage.spawn(null, () => { + let win = Cu.waiveXrays(this.iframeWindow); + ok( + !Cu.isDeadWrapper(win.browser), + "the API object should not be a dead object" + ); + + let manifest; + let manifestException; + try { + manifest = win.browser.runtime.getManifest(); + } catch (e) { + manifestException = e; + } + + Assert.ok(!manifest, "manifest should be undefined"); + + Assert.equal( + manifestException.constructor.name, + "TypeError", + "expected exception received" + ); + + Assert.ok( + manifestException.message.endsWith("win.browser.runtime is undefined"), + "expected exception received" + ); + + let getManifestException = win.testGetManifestException(); + + Assert.equal( + getManifestException, + "TypeError: can't access dead object", + "expected exception received" + ); + }); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js new file mode 100644 index 0000000000..cf770d91b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_csp.js @@ -0,0 +1,355 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +const server = createHttpServer({ + hosts: ["example.com", "csplog.example.net"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +var gDefaultCSP = `default-src 'self' 'report-sample'; script-src 'self' 'report-sample';`; +var gCSP = gDefaultCSP; +const pageContent = `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body> + <img id="testimg"> + </body> + </html>`; + +server.registerPathHandler("/plain.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (gCSP) { + info(`Content-Security-Policy: ${gCSP}`); + response.setHeader("Content-Security-Policy", gCSP); + } + response.write(pageContent); +}); + +const BASE_URL = `http://example.com`; +const pageURL = `${BASE_URL}/plain.html`; + +const CSP_REPORT_PATH = "/csp-report.sjs"; + +function readUTF8InputStream(stream) { + let buffer = NetUtil.readInputStream(stream, stream.available()); + return new TextDecoder().decode(buffer); +} + +server.registerPathHandler(CSP_REPORT_PATH, (request, response) => { + response.setStatusLine(request.httpVersion, 204, "No Content"); + let data = readUTF8InputStream(request.bodyInputStream); + Services.obs.notifyObservers(null, "extension-test-csp-report", data); +}); + +async function promiseCSPReport(test) { + let res = await TestUtils.topicObserved("extension-test-csp-report", test); + return JSON.parse(res[1]); +} + +// Test functions loaded into extension content script. +function testImage(data = {}) { + return new Promise(resolve => { + let img = window.document.getElementById("testimg"); + img.onload = () => resolve(true); + img.onerror = () => { + browser.test.log(`img error: ${img.src}`); + resolve(false); + }; + img.src = data.image_url; + }); +} + +function testFetch(data = {}) { + let f = data.content ? content.fetch : fetch; + return f(data.url) + .then(() => true) + .catch(e => { + browser.test.assertEq( + e.message, + "NetworkError when attempting to fetch resource.", + "expected fetch failure" + ); + return false; + }); +} + +async function testEval(data = {}) { + try { + // eslint-disable-next-line no-eval + let ev = data.content ? window.eval : eval; + return ev("true"); + } catch (e) { + return false; + } +} + +async function testFunction(data = {}) { + try { + // eslint-disable-next-line no-eval + let fn = data.content ? window.Function : Function; + let sum = new fn("a", "b", "return a + b"); + return sum(1, 1); + } catch (e) { + return 0; + } +} + +function testScriptTag(data) { + return new Promise(resolve => { + let script = document.createElement("script"); + script.src = data.url; + script.onload = () => { + resolve(true); + }; + script.onerror = () => { + resolve(false); + }; + document.body.appendChild(script); + }); +} + +// If the violation source is the extension the securitypolicyviolation event is not fired. +// If the page is the source, the event is fired and both the content script or page scripts +// will receive the event. If we're expecting a moz-extension report we'll fail in the +// event listener if we receive a report. Otherwise we want to resolve in the listener to +// ensure we've received the event for the test. +function contentScript(report) { + return new Promise(resolve => { + if (!report || report["document-uri"] === "moz-extension") { + resolve(); + } + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("securitypolicyviolation", e => { + browser.test.assertTrue( + e.documentURI !== "moz-extension", + `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}` + ); + resolve(); + }); + }); +} + +let TESTS = [ + // Image Tests + { + description: + "Image from content script using default extension csp. Image is allowed.", + pageCSP: `${gDefaultCSP} img-src 'none';`, + script: testImage, + data: { image_url: `${BASE_URL}/data/file_image_good.png` }, + expect: true, + }, + // Fetch Tests + { + description: "Fetch url in content script uses default extension csp.", + pageCSP: `${gDefaultCSP} connect-src 'none';`, + script: testFetch, + data: { url: `${BASE_URL}/data/file_image_good.png` }, + expect: true, + }, + { + description: "Fetch full url from content script uses page csp.", + pageCSP: `${gDefaultCSP} connect-src 'none';`, + script: testFetch, + data: { + content: true, + url: `${BASE_URL}/data/file_image_good.png`, + }, + expect: false, + report: { + "blocked-uri": `${BASE_URL}/data/file_image_good.png`, + "document-uri": `${BASE_URL}/plain.html`, + "violated-directive": "connect-src", + }, + }, + { + description: "Fetch url from content script uses page csp.", + pageCSP: `${gDefaultCSP} connect-src *;`, + script: testFetch, + version: 3, + data: { + content: true, + url: `${BASE_URL}/data/file_image_good.png`, + }, + expect: true, + }, + + // Eval tests. + { + description: "Eval from content script uses page csp with unsafe-eval.", + pageCSP: `default-src 'none'; script-src 'unsafe-eval';`, + script: testEval, + data: { content: true }, + expect: true, + }, + { + description: "Eval from content script uses page csp.", + pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`, + version: 3, + script: testEval, + data: { content: true }, + expect: false, + report: { + "blocked-uri": "eval", + "document-uri": "http://example.com/plain.html", + "violated-directive": "script-src", + }, + }, + { + description: "Eval in content script allowed by v2 csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + script: testEval, + expect: true, + }, + { + description: "Eval in content script disallowed by v3 csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + version: 3, + script: testEval, + expect: false, + }, + { + description: "Wrapped Eval in content script uses page csp.", + pageCSP: `script-src 'self' 'unsafe-eval';`, + version: 3, + script: async () => { + return window.wrappedJSObject.eval("true"); + }, + expect: true, + }, + { + description: "Wrapped Eval in content script denied by page csp.", + pageCSP: `script-src 'self';`, + version: 3, + script: async () => { + try { + return window.wrappedJSObject.eval("true"); + } catch (e) { + return false; + } + }, + expect: false, + }, + + { + description: "Function from content script uses page csp.", + pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`, + script: testFunction, + data: { content: true }, + expect: 2, + }, + { + description: "Function from content script uses page csp.", + pageCSP: `default-src 'self' 'report-sample'; script-src 'self';`, + version: 3, + script: testFunction, + data: { content: true }, + expect: 0, + report: { + "blocked-uri": "eval", + "document-uri": "http://example.com/plain.html", + "violated-directive": "script-src", + }, + }, + { + description: "Function in content script uses extension csp.", + pageCSP: `default-src 'self'; script-src 'self' 'unsafe-eval';`, + version: 3, + script: testFunction, + expect: 0, + }, + + // The javascript url tests are not included as we do not execute those, + // aparently even with the urlbar filtering pref flipped. + // (browser.urlbar.filter.javascript) + // https://bugzilla.mozilla.org/show_bug.cgi?id=866522 + + // script tag injection tests + { + description: "remote script in content script passes in v2", + version: 2, + pageCSP: "script-src http://example.com:*;", + script: testScriptTag, + data: { url: `${BASE_URL}/data/file_script_good.js` }, + expect: true, + }, + { + description: "remote script in content script fails in v3", + version: 3, + pageCSP: "script-src http://example.com:*;", + script: testScriptTag, + data: { url: `${BASE_URL}/data/file_script_good.js` }, + expect: false, + }, +]; + +async function runCSPTest(test) { + // Set the CSP for the page loaded into the tab. + gCSP = `${test.pageCSP || gDefaultCSP} report-uri ${CSP_REPORT_PATH}`; + let data = { + manifest: { + manifest_version: test.version || 2, + content_scripts: [ + { + matches: ["http://*/plain.html"], + run_at: "document_idle", + js: ["content_script.js"], + }, + ], + permissions: ["<all_urls>"], + }, + + files: { + "content_script.js": ` + (${contentScript})(${JSON.stringify(test.report)}).then(() => { + browser.test.sendMessage("violationEvent"); + }); + (${test.script})(${JSON.stringify(test.data)}).then(result => { + browser.test.sendMessage("result", result); + }); + `, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + let reportPromise = test.report && promiseCSPReport(); + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + info(`running: ${test.description}`); + await extension.awaitMessage("violationEvent"); + let result = await extension.awaitMessage("result"); + equal(result, test.expect, test.description); + if (test.report) { + let report = await reportPromise; + for (let key of Object.keys(test.report)) { + equal( + report["csp-report"][key], + test.report[key], + `csp-report ${key} matches` + ); + } + } + + await extension.unload(); + await contentPage.close(); + clearCache(); +} + +add_task(async function test_contentscript_csp() { + for (let test of TESTS) { + await runCSPTest(test); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js new file mode 100644 index 0000000000..d94023387f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_css.js @@ -0,0 +1,48 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_content_script_css() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/dummy"], + css: ["content.css"], + run_at: "document_start", + }, + ], + }, + + files: { + "content.css": "body { max-width: 42px; }", + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + function task() { + let style = this.content.getComputedStyle(this.content.document.body); + return style.maxWidth; + } + + let maxWidth = await contentPage.spawn(null, task); + equal(maxWidth, "42px", "Stylesheet correctly applied"); + + await extension.unload(); + + maxWidth = await contentPage.spawn(null, task); + equal(maxWidth, "none", "Stylesheet correctly removed"); + + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js new file mode 100644 index 0000000000..f485a012c9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_exporthelpers.js @@ -0,0 +1,98 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_exportHelpers() { + function contentScript() { + browser.test.assertTrue(typeof cloneInto === "function"); + browser.test.assertTrue(typeof createObjectIn === "function"); + browser.test.assertTrue(typeof exportFunction === "function"); + + /* globals exportFunction, precisePi, reportPi */ + let value = 3.14; + exportFunction(() => value, window, { defineAs: "precisePi" }); + + browser.test.assertEq( + "undefined", + typeof precisePi, + "exportFunction should export to the page's scope only" + ); + + browser.test.assertEq( + "undefined", + typeof window.precisePi, + "exportFunction should export to the page's scope only" + ); + + let results = []; + exportFunction(pi => results.push(pi), window, { defineAs: "reportPi" }); + + let s = document.createElement("script"); + s.textContent = `(${function() { + let result1 = "unknown 1"; + let result2 = "unknown 2"; + try { + result1 = precisePi(); + } catch (e) { + result1 = "err:" + e; + } + try { + result2 = window.precisePi(); + } catch (e) { + result2 = "err:" + e; + } + reportPi(result1); + reportPi(result2); + }})();`; + + document.documentElement.appendChild(s); + // Inline script ought to run synchronously. + + browser.test.assertEq( + 3.14, + results[0], + "exportFunction on window should define a global function" + ); + browser.test.assertEq( + 3.14, + results[1], + "exportFunction on window should export a property to window." + ); + + browser.test.assertEq( + 2, + results.length, + "Expecting the number of results to match the number of method calls" + ); + + browser.test.notifyPass("export helper test completed"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/data/file_sample.html"], + run_at: "document_start", + }, + ], + }, + + files: { + "contentscript.js": contentScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("export helper test completed"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js new file mode 100644 index 0000000000..1a8aa6d706 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_in_background.js @@ -0,0 +1,61 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/dummyFrame", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write(""); +}); + +add_task(async function connect_from_background_frame() { + async function background() { + const FRAME_URL = "http://example.com:8888/dummyFrame"; + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.sender.tab, undefined, "Sender is not a tab"); + browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL"); + port.onMessage.addListener(msg => { + browser.test.assertEq("pong", msg, "Reply from content script"); + port.disconnect(); + }); + port.postMessage("ping"); + }); + + await browser.contentScripts.register({ + matches: ["http://example.com/dummyFrame"], + js: [{ file: "contentscript.js" }], + allFrames: true, + }); + + let f = document.createElement("iframe"); + f.src = FRAME_URL; + document.body.appendChild(f); + } + + function contentScript() { + browser.test.log(`Running content script at ${document.URL}`); + + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq("ping", msg, "Expected message to content script"); + port.postMessage("pong"); + }); + port.onDisconnect.addListener(() => { + browser.test.sendMessage("disconnected_in_content_script"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + }, + files: { + "contentscript.js": contentScript, + }, + background, + }); + await extension.startup(); + await extension.awaitMessage("disconnected_in_content_script"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js new file mode 100644 index 0000000000..484c41ad3f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_perf_observers.js @@ -0,0 +1,71 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["a.example.com", "b.example.com", "c.example.com"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_perf_observers_cors() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://b.example.com/"], + content_scripts: [ + { + matches: ["http://a.example.com/file_sample.html"], + js: ["cs.js"], + }, + ], + }, + files: { + "cs.js"() { + let obs = new window.PerformanceObserver(list => { + list.getEntries().forEach(e => { + browser.test.sendMessage("observed", { + url: e.name, + time: e.connectEnd, + size: e.encodedBodySize, + }); + }); + }); + obs.observe({ entryTypes: ["resource"] }); + + let b = document.createElement("link"); + b.rel = "stylesheet"; + + // Simulate page including a cross-origin resource from b.example.com. + b.wrappedJSObject.href = "http://b.example.com/file_download.txt"; + document.head.appendChild(b); + + let c = document.createElement("link"); + c.rel = "stylesheet"; + + // Simulate page including a cross-origin resource from c.example.com. + c.wrappedJSObject.href = "http://c.example.com/file_download.txt"; + document.head.appendChild(c); + }, + }, + }); + + let page = await ExtensionTestUtils.loadContentPage( + "http://a.example.com/file_sample.html" + ); + await extension.startup(); + + let b = await extension.awaitMessage("observed"); + let c = await extension.awaitMessage("observed"); + + if (b.url.startsWith("http://c.")) { + [c, b] = [b, c]; + } + + ok(b.url.startsWith("http://b."), "Observed resource from b.example.com"); + ok(b.time > 0, "connectionEnd available from b.example.com"); + equal(b.size, 428, "encodedBodySize available from b.example.com"); + + ok(c.url.startsWith("http://c."), "Observed resource from c.example.com"); + equal(c.time, 0, "connectionEnd == 0 from c.example.com"); + equal(c.size, 0, "encodedBodySize == 0 from c.example.com"); + + await extension.unload(); + await page.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js new file mode 100644 index 0000000000..e9b1dbe57c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_restrictSchemes.js @@ -0,0 +1,70 @@ +"use strict"; + +function makeExtension(id, isPrivileged) { + return ExtensionTestUtils.loadExtension({ + isPrivileged, + + manifest: { + applications: { gecko: { id } }, + + permissions: isPrivileged ? ["mozillaAddons"] : [], + + content_scripts: [ + { + matches: ["resource://foo/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + + files: { + "content_script.js"() { + browser.test.assertEq( + "resource://foo/file_sample.html", + document.documentURI, + `Loaded content script into the correct document (extension: ${browser.runtime.id})` + ); + browser.test.sendMessage(`content-script-${browser.runtime.id}`); + }, + }, + }); +} + +add_task(async function test_contentscript_restrictSchemes() { + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitutionWithFlags( + "foo", + Services.io.newFileURI(do_get_file("data")), + resProto.ALLOW_CONTENT_ACCESS + ); + + let unprivileged = makeExtension("unprivileged@tests.mozilla.org", false); + let privileged = makeExtension("privileged@tests.mozilla.org", true); + + await unprivileged.startup(); + await privileged.startup(); + + unprivileged.onMessage( + "content-script-unprivileged@tests.mozilla.org", + () => { + ok( + false, + "Unprivileged extension executed content script on resource URL" + ); + } + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `resource://foo/file_sample.html` + ); + + await privileged.awaitMessage("content-script-privileged@tests.mozilla.org"); + + await contentPage.close(); + + await privileged.unload(); + await unprivileged.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js new file mode 100644 index 0000000000..e0ed263065 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_scriptCreated.js @@ -0,0 +1,61 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Test that document_start content scripts don't block script-created +// parsers. +add_task(async function test_contentscript_scriptCreated() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_document_write.html"], + js: ["content_script.js"], + run_at: "document_start", + match_about_blank: true, + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": function() { + if (window === top) { + addEventListener( + "message", + msg => { + browser.test.assertEq( + "ok", + msg.data, + "document.write() succeeded" + ); + browser.test.sendMessage("content-script-done"); + }, + { once: true } + ); + } + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_document_write.html` + ); + + await extension.awaitMessage("content-script-done"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js new file mode 100644 index 0000000000..2bf7981657 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_teardown.js @@ -0,0 +1,102 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript_reload_and_unload() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["contentscript.js"], + }, + ], + }, + + files: { + "contentscript.js"() { + browser.test.sendMessage("contentscript-run"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let events = []; + { + let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + const tabUrl = "http://example.com/data/file_sample.html"; + let contentPage = await ExtensionTestUtils.loadContentPage(tabUrl); + + await extension.awaitMessage("contentscript-run"); + + let contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after loading a content script" + ); + equal( + contextEvents[0].eventType, + "load", + "Create ExtensionContext for content script" + ); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + + await contentPage.spawn(null, () => { + this.content.location.reload(); + }); + await extension.awaitMessage("contentscript-run"); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 2, + "ExtensionContext state changes after reloading a content script" + ); + equal(contextEvents[0].eventType, "unload", "Unload old ExtensionContext"); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + equal( + contextEvents[1].eventType, + "load", + "Create new ExtensionContext for content script" + ); + equal(contextEvents[1].url, tabUrl, "ExtensionContext URL = page"); + + await contentPage.close(); + + contextEvents = events.splice(0); + equal( + contextEvents.length, + 1, + "ExtensionContext state change after unloading a content script" + ); + equal( + contextEvents[0].eventType, + "unload", + "Unload ExtensionContext after closing the tab with the content script" + ); + equal(contextEvents[0].url, tabUrl, "ExtensionContext URL = page"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js new file mode 100644 index 0000000000..f5df8e61d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_triggeringPrincipal.js @@ -0,0 +1,1373 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/** + * Tests that various types of inline content elements initiate requests + * with the triggering pringipal of the caller that requested the load, + * and that the correct security policies are applied to the resulting + * loads. + */ + +const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment +); + +// Make sure media pre-loading is enabled on Android so that our <audio> and +// <video> elements trigger the expected requests. +Services.prefs.setIntPref("media.autoplay.default", Ci.nsIAutoplay.ALLOWED); +Services.prefs.setIntPref("media.preload.default", 3); + +// Increase the length of the code samples included in CSP reports so that we +// can correctly validate them. +Services.prefs.setIntPref( + "security.csp.reporting.script-sample.max-length", + 4096 +); + +// Do not trunacate the blocked-uri in CSP reports for frame navigations. +Services.prefs.setBoolPref( + "security.csp.truncate_blocked_uri_for_frame_navigations", + false +); + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer({ + hosts: ["example.com", "csplog.example.net"], +}); + +server.registerDirectory("/data/", do_get_file("data")); + +var gContentSecurityPolicy = null; + +const BASE_URL = `http://example.com`; +const CSP_REPORT_PATH = "/csp-report.sjs"; + +/** + * Registers a static HTML document with the given content at the given + * path in our test HTTP server. + * + * @param {string} path + * @param {string} content + */ +function registerStaticPage(path, content) { + server.registerPathHandler(path, (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (gContentSecurityPolicy) { + response.setHeader("Content-Security-Policy", gContentSecurityPolicy); + } + response.write(content); + }); +} + +/** + * A set of tags which are automatically closed in HTML documents, and + * do not require an explicit closing tag. + */ +const AUTOCLOSE_TAGS = new Set(["img", "input", "link", "source"]); + +/** + * An object describing the elements to create for a specific test. + * + * @typedef {object} ElementTestCase + * @property {Array} element + * A recursive array, describing the element to create, in the + * following format: + * + * ["tagname", {attr: "attrValue"}, + * ["child-tagname", {attr: "value"}], + * ...] + * + * For each test, a DOM tree will be created with this structure. + * A source attribute, with the name `test.srcAttr` and a value + * based on the values of `test.src` and `opts`, will be added to + * the first leaf node encountered. + * @property {string} src + * The relative URL to use as the source of the element. Each + * load of this URL will have a separate set of query parameters + * appended to it, based on the values in `opts`. + * @property {string} [srcAttr = "src"] + * The attribute in which to store the element's source URL. + * @property {string} [srcAttr = "src"] + * The attribute in which to store the element's source URL. + * @property {boolean} [liveSrc = false] + * If true, changing the source attribute after the element has + * been inserted into the document is expected to trigger a new + * load, and that configuration will be tested. + */ + +/** + * Options for this specific configuration of an element test. + * + * @typedef {object} ElementTestOptions + * @property {string} origin + * The origin with which the content is expected to load. This + * may be one of "page", "contentScript", or "extension". The actual load + * of the URL will be tested against the computed origin strings for + * those two contexts. + * @property {string} source + * An arbitrary string which uniquely identifies the source of + * the load. For instance, each of these should have separate + * origin strings: + * + * - An element present in the initial page HTML. + * - An element injected by a page script belonging to web + * content. + * - An element injected by an extension content script. + */ + +/** + * Data describing a test element, which can be used to create a + * corresponding DOM tree. + * + * @typedef {object} ElementData + * @property {string} tagName + * The tag name for the element. + * @property {object} attrs + * A property containing key-value pairs for each of the + * attribute's elements. + * @property {Array<ElementData>} children + * A possibly empty array of element data for child elements. + */ + +/** + * Returns data necessary to create test elements for the given test, + * with the given options. + * + * @param {ElementTestCase} test + * An object describing the elements to create for a specific + * test. This element will be created under various + * circumstances, as described by `opts`. + * @param {ElementTestOptions} opts + * Options for this specific configuration of the test. + * @returns {ElementData} + */ +function getElementData(test, opts) { + let baseURL = typeof BASE_URL !== "undefined" ? BASE_URL : location.href; + + let { srcAttr, src } = test; + + // Absolutify the URL, so it passes sanity checks that ignore + // triggering principals for relative URLs. + src = new URL( + src + + `?origin=${encodeURIComponent(opts.origin)}&source=${encodeURIComponent( + opts.source + )}`, + baseURL + ).href; + + let haveSrc = false; + function rec(element) { + let [tagName, attrs, ...children] = element; + + if (children.length) { + children = children.map(rec); + } else if (!haveSrc) { + attrs = Object.assign({ [srcAttr]: src }, attrs); + haveSrc = true; + } + + return { tagName, attrs, children }; + } + return rec(test.element); +} + +/** + * The result type of the {@see createElement} function. + * + * @typedef {object} CreateElementResult + * @property {Element} elem + * The root element of the created DOM tree. + * @property {Element} srcElem + * The element in the tree to which the source attribute must be + * added. + * @property {string} src + * The value of the source element. + */ + +/** + * Creates a DOM tree for a given test, in a given configuration, as + * understood by {@see getElementData}, but without the `test.srcAttr` + * attribute having been set. The caller must set the value of that + * attribute to the returned `src` value. + * + * There are many different ways most source values can be set + * (DOM attribute, DOM property, ...) and many different contexts + * (content script verses page script). Each test should be run with as + * many variants of these as possible. + * + * @param {ElementTestCase} test + * A test object, as passed to {@see getElementData}. + * @param {ElementTestOptions} opts + * An options object, as passed to {@see getElementData}. + * @returns {CreateElementResult} + */ +function createElement(test, opts) { + let srcElem; + let src; + + function rec({ tagName, attrs, children }) { + let elem = document.createElement(tagName); + + for (let [key, val] of Object.entries(attrs)) { + if (key === test.srcAttr) { + srcElem = elem; + src = val; + } else { + elem.setAttribute(key, val); + } + } + for (let child of children) { + elem.appendChild(rec(child)); + } + return elem; + } + let elem = rec(getElementData(test, opts)); + + return { elem, srcElem, src }; +} + +/** + * Escapes any occurrences of &, ", < or > with XML entities. + * + * @param {string} str + * The string to escape. + * @returns {string} The escaped string. + */ +function escapeXML(str) { + let replacements = { + "&": "&", + '"': """, + "'": "'", + "<": "<", + ">": ">", + }; + return String(str).replace(/[&"''<>]/g, m => replacements[m]); +} + +/** + * A tagged template function which escapes any XML metacharacters in + * interpolated values. + * + * @param {Array<string>} strings + * An array of literal strings extracted from the templates. + * @param {Array} values + * An array of interpolated values extracted from the template. + * @returns {string} + * The result of the escaped values interpolated with the literal + * strings. + */ +function escaped(strings, ...values) { + let result = []; + + for (let [i, string] of strings.entries()) { + result.push(string); + if (i < values.length) { + result.push(escapeXML(values[i])); + } + } + + return result.join(""); +} + +/** + * Converts the given test data, as accepted by {@see getElementData}, + * to an HTML representation. + * + * @param {ElementTestCase} test + * A test object, as passed to {@see getElementData}. + * @param {ElementTestOptions} opts + * An options object, as passed to {@see getElementData}. + * @returns {string} + */ +function toHTML(test, opts) { + function rec({ tagName, attrs, children }) { + let html = [`<${tagName}`]; + for (let [key, val] of Object.entries(attrs)) { + html.push(escaped` ${key}="${val}"`); + } + + html.push(">"); + if (!AUTOCLOSE_TAGS.has(tagName)) { + for (let child of children) { + html.push(rec(child)); + } + + html.push(`</${tagName}>`); + } + return html.join(""); + } + return rec(getElementData(test, opts)); +} + +/** + * Injects various permutations of inline CSS into a content page, from both + * extension content script and content page contexts, and sends a "css-sources" + * message to the test harness describing the injected content for verification. + */ +function testInlineCSS() { + let urls = []; + let sources = []; + + /** + * Constructs the URL of an image to be loaded by the given origin, and + * returns a CSS url() expression for it. + * + * The `name` parameter is an arbitrary name which should describe how the URL + * is loaded. The `opts` object may contain arbitrary properties which + * describe the load. Currently, only `inline` is recognized, and indicates + * that the URL is being used in an inline stylesheet which may be blocked by + * CSP. + * + * The URL and its parameters are recorded, and sent to the parent process for + * verification. + * + * @param {string} origin + * @param {string} name + * @param {object} [opts] + * @returns {string} + */ + let i = 0; + let url = (origin, name, opts = {}) => { + let source = `${origin}-${name}`; + + let { href } = new URL( + `css-${i++}.png?origin=${encodeURIComponent( + origin + )}&source=${encodeURIComponent(source)}`, + location.href + ); + + urls.push(Object.assign({}, opts, { href, origin, source })); + return `url("${href}")`; + }; + + /** + * Registers the given inline CSS source as being loaded by the given origin, + * and returns that CSS text. + * + * @param {string} origin + * @param {string} css + * @returns {string} + */ + let source = (origin, css) => { + sources.push({ origin, css }); + return css; + }; + + /** + * Saves the given function to be run after a short delay, just before sending + * the list of loaded sources to the parent process. + */ + let laters = []; + let later = fn => { + laters.push(fn); + }; + + // Note: When accessing an element through `wrappedJSObject`, the operations + // occur in the content page context, using the content subject principal. + // When accessing it through X-ray wrappers, they happen in the content script + // context, using its subject principal. + + { + let li = document.createElement("li"); + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-first")}` + ) + ); + li.style.wrappedJSObject.listStyleImage = url( + "page", + "li.style.listStyleImage-second" + ); + document.body.appendChild(li); + } + + { + let li = document.createElement("li"); + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-first", { inline: true })}` + ) + ); + li.style.listStyleImage = url( + "contentScript", + "li.style.listStyleImage-second" + ); + document.body.appendChild(li); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-first")}` + ) + ); + later(() => + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-second", { inline: true })}` + ) + ) + ); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.wrappedJSObject.setAttribute( + "style", + source( + "page", + `background: ${url("page", "li.style-first", { inline: true })}` + ) + ); + later(() => + li.setAttribute( + "style", + source( + "contentScript", + `background: ${url("contentScript", "li.style-second")}` + ) + ) + ); + } + + { + let li = document.createElement("li"); + document.body.appendChild(li); + li.style.cssText = source( + "contentScript", + `background: ${url("contentScript", "li.style.cssText-first")}` + ); + + // TODO: This inline style should be blocked, since our style-src does not + // include 'unsafe-eval', but that is currently unimplemented. + later(() => { + li.style.wrappedJSObject.cssText = `background: ${url( + "page", + "li.style.cssText-second" + )}`; + }); + } + + // Creates a new element, inserts it into the page, and returns its CSS selector. + let divNum = 0; + function getSelector() { + let div = document.createElement("div"); + div.id = `generated-div-${divNum++}`; + document.body.appendChild(div); + return `#${div.id}`; + } + + for (let prop of ["textContent", "innerHTML"]) { + // Test creating <style> element from the extension side and then replacing + // its contents from the content side. + { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url("extension", `style-${prop}-first`)}; }` + ); + document.head.appendChild(style); + + later(() => { + style.wrappedJSObject[prop] = source( + "page", + `${sel} { background: ${url("page", `style-${prop}-second`, { + inline: true, + })}; }` + ); + }); + } + + // Test creating <style> element from the extension side and then appending + // a text node to it. Regardless of whether the append happens from the + // content or extension side, this should cause the principal to be + // forgotten. + let testModifyAfterInject = (name, modifyFunc) => { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url( + "extension", + `style-${name}-${prop}-first` + )}; }` + ); + document.head.appendChild(style); + + later(() => { + modifyFunc( + style, + `${sel} { background: ${url("page", `style-${name}-${prop}-second`, { + inline: true, + })}; }` + ); + source("page", style.textContent); + }); + }; + + testModifyAfterInject("appendChild", (style, css) => { + style.appendChild(document.createTextNode(css)); + }); + + // Test creating <style> element from the extension side and then appending + // to it using insertAdjacentHTML, with the same rules as above. + testModifyAfterInject("insertAdjacentHTML", (style, css) => { + // eslint-disable-next-line no-unsanitized/method + style.insertAdjacentHTML("beforeend", css); + }); + + // And again using insertAdjacentText. + testModifyAfterInject("insertAdjacentText", (style, css) => { + style.insertAdjacentText("beforeend", css); + }); + + // Test creating a style element and then accessing its CSSStyleSheet object. + { + let sel = getSelector(); + let style = document.createElement("style"); + style[prop] = source( + "extension", + `${sel} { background: ${url("extension", `style-${prop}-sheet`)}; }` + ); + document.head.appendChild(style); + + browser.test.assertThrows( + () => style.sheet.wrappedJSObject.cssRules, + /Not allowed to access cross-origin stylesheet/, + "Page content should not be able to access extension-generated CSS rules" + ); + + style.sheet.insertRule( + source( + "extension", + `${sel} { border-image: ${url( + "extension", + `style-${prop}-sheet-insertRule` + )}; }` + ) + ); + } + } + + setTimeout(() => { + for (let fn of laters) { + fn(); + } + browser.test.sendMessage("css-sources", { urls, sources }); + }); +} + +/** + * A function which will be stringified, and run both as a page script + * and an extension content script, to test element injection under + * various configurations. + * + * @param {Array<ElementTestCase>} tests + * A list of test objects, as understood by {@see getElementData}. + * @param {ElementTestOptions} baseOpts + * A base options object, as understood by {@see getElementData}, + * which represents the default values for injections under this + * context. + */ +function injectElements(tests, baseOpts) { + window.addEventListener( + "load", + () => { + if (typeof browser === "object") { + try { + testInlineCSS(); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + } + } + + // Basic smoke test to check that SVG images do not try to create a document + // with an expanded principal, which would cause a crash. + let img = document.createElement("img"); + img.src = "data:image/svg+xml,%3Csvg%2F%3E"; + document.body.appendChild(img); + + let rand = Math.random(); + + // Basic smoke test to check that we don't try to create stylesheets with an + // expanded principal, which would cause a crash when loading font sets. + let cssText = ` + @font-face { + font-family: "DoesNotExist${rand}"; + src: url("fonts/DoesNotExist.${rand}.woff") format("woff"); + font-weight: normal; + font-style: normal; + }`; + + let link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "data:text/css;base64," + btoa(cssText); + document.head.appendChild(link); + + let style = document.createElement("style"); + style.textContent = cssText; + document.head.appendChild(style); + + let overrideOpts = opts => Object.assign({}, baseOpts, opts); + let opts = baseOpts; + + // Build the full element with setAttr, then inject. + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, src); + document.body.appendChild(elem); + } + + // Build the full element with a property setter. + opts = overrideOpts({ source: `${baseOpts.source}-prop` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem[test.srcAttr] = src; + document.body.appendChild(elem); + } + + // Build the element without the source attribute, inject, then set + // it. + opts = overrideOpts({ source: `${baseOpts.source}-attr-after-inject` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + document.body.appendChild(elem); + srcElem.setAttribute(test.srcAttr, src); + } + + // Build the element without the source attribute, inject, then set + // the corresponding property. + opts = overrideOpts({ source: `${baseOpts.source}-prop-after-inject` }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + document.body.appendChild(elem); + srcElem[test.srcAttr] = src; + } + + // Build the element with a relative, rather than absolute, URL, and + // make sure it always has the page origin. + opts = overrideOpts({ + source: `${baseOpts.source}-relative-url`, + origin: "page", + }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + // Note: This assumes that the content page and the src URL are + // always at the server root. If that changes, the test will + // timeout waiting for matching requests. + src = src.replace(/.*\//, ""); + srcElem.setAttribute(test.srcAttr, src); + document.body.appendChild(elem); + } + + // If we're in an extension content script, do some additional checks. + if (typeof browser !== "undefined") { + // Build the element without the source attribute, inject, then + // have content set it. + opts = overrideOpts({ + source: `${baseOpts.source}-content-attr-after-inject`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + + document.body.appendChild(elem); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + } + + // Build the full element, then let content inject. + opts = overrideOpts({ + source: `${baseOpts.source}-content-inject-after-attr`, + }); + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, src); + window.wrappedJSObject.elem = elem; + window.wrappedJSObject.eval(`document.body.appendChild(elem)`); + } + + // Build the element without the source attribute, let content set + // it, then inject. + opts = overrideOpts({ + source: `${baseOpts.source}-inject-after-content-attr`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + document.body.appendChild(elem); + } + + // Build the element with a dummy source attribute, inject, then + // let content change it. + opts = overrideOpts({ + source: `${baseOpts.source}-content-change-after-inject`, + origin: "page", + }); + + for (let test of tests) { + let { elem, srcElem, src } = createElement(test, opts); + srcElem.setAttribute(test.srcAttr, "meh.txt"); + document.body.appendChild(elem); + window.wrappedJSObject.elem = srcElem; + window.wrappedJSObject.eval( + `elem.setAttribute(${JSON.stringify( + test.srcAttr + )}, ${JSON.stringify(src)})` + ); + } + } + }, + { once: true } + ); +} + +/** + * Stringifies the {@see injectElements} function for use as a page or + * content script. + * + * @param {Array<ElementTestCase>} tests + * A list of test objects, as understood by {@see getElementData}. + * @param {ElementTestOptions} opts + * A base options object, as understood by {@see getElementData}, + * which represents the default values for injections under this + * context. + * @returns {string} + */ +function getInjectionScript(tests, opts) { + return ` + ${getElementData} + ${createElement} + ${testInlineCSS} + (${injectElements})(${JSON.stringify(tests)}, + ${JSON.stringify(opts)}); + `; +} + +/** + * Extracts the "origin" query parameter from the given URL, and returns it, + * along with the URL sans origin parameter. + * + * @param {string} origURL + * @returns {object} + * An object with `origin` and `baseURL` properties, containing the value + * or the URL's "origin" query parameter and the URL with that parameter + * removed, respectively. + */ +function getOriginBase(origURL) { + let url = new URL(origURL); + let origin = url.searchParams.get("origin"); + url.searchParams.delete("origin"); + + return { origin, baseURL: url.href }; +} + +/** + * An object containing sets of base URLs and CSS sources which are present in + * the test page, sorted based on how they should be treated by CSP. + * + * @typedef {object} RequestedURLs + * @property {Set<string>} expectedURLs + * A set of URLs which should be successfully requested by the content + * page. + * @property {Set<string>} forbiddenURLs + * A set of URLs which are present in the content page, but should never + * generate requests. + * @property {Set<string>} blockedURLs + * A set of URLs which are present in the content page, and should be + * blocked by CSP, and reported in a CSP report. + * @property {Set<string>} blockedSources + * A set of inline CSS sources which should be blocked by CSP, and + * reported in a CSP report. + */ + +/** + * Computes a list of expected and forbidden base URLs for the given + * sets of tests and sources. The base URL is the complete request URL + * with the `origin` query parameter removed. + * + * @param {Array<ElementTestCase>} tests + * A list of tests, as understood by {@see getElementData}. + * @param {Object<string, object>} expectedSources + * A set of sources for which each of the above tests is expected + * to generate one request, if each of the properties in the + * value object matches the value of the same property in the + * test object. + * @param {Object<string, object>} [forbiddenSources = {}] + * A set of sources for which requests should never be sent. Any + * matching requests from these sources will cause the test to + * fail. + * @returns {RequestedURLs} + */ +function computeBaseURLs(tests, expectedSources, forbiddenSources = {}) { + let expectedURLs = new Set(); + let forbiddenURLs = new Set(); + + function* iterSources(test, sources) { + for (let [source, attrs] of Object.entries(sources)) { + // if a source defines attributes (e.g. liveSrc in PAGE_SOURCES etc.) then all + // attributes in the source must be matched by the test (see const TEST). + if (Object.keys(attrs).every(attr => attrs[attr] === test[attr])) { + yield `${BASE_URL}/${test.src}?source=${source}`; + } + } + } + + for (let test of tests) { + for (let urlPrefix of iterSources(test, expectedSources)) { + expectedURLs.add(urlPrefix); + } + for (let urlPrefix of iterSources(test, forbiddenSources)) { + forbiddenURLs.add(urlPrefix); + } + } + + return { expectedURLs, forbiddenURLs, blockedURLs: forbiddenURLs }; +} + +/** + * Generates a set of expected and forbidden URLs and sources based on the CSS + * injected by our content script. + * + * @param {object} message + * The "css-sources" message sent by the content script, containing lists + * of CSS sources injected into the page. + * @param {Array<object>} message.urls + * A list of URLs present in styles injected by the content script. + * @param {string} message.urls.*.origin + * The origin of the URL, one of "page", "contentScript", or "extension". + * @param {string} message.urls.*.href + * The URL string. + * @param {boolean} message.urls.*.inline + * If true, the URL is present in an inline stylesheet, which may be + * blocked by CSP prior to parsing, depending on its origin. + * @param {Array<object>} message.sources + * A list of inline CSS sources injected by the content script. + * @param {string} message.sources.*.origin + * The origin of the CSS, one of "page", "contentScript", or "extension". + * @param {string} message.sources.*.css + * The CSS source text. + * @param {boolean} [cspEnabled = false] + * If true, a strict CSP is enabled for this page, and inline page + * sources should be blocked. URLs present in these sources will not be + * expected to generate a CSP report, the inline sources themselves will. + * @param {boolean} [contentCspEnabled = false] + * @returns {RequestedURLs} + */ +function computeExpectedForbiddenURLs( + { urls, sources }, + cspEnabled = false, + contentCspEnabled = false +) { + let expectedURLs = new Set(); + let forbiddenURLs = new Set(); + let blockedURLs = new Set(); + let blockedSources = new Set(); + + for (let { href, origin, inline } of urls) { + let { baseURL } = getOriginBase(href); + if (cspEnabled && origin === "page") { + if (inline) { + forbiddenURLs.add(baseURL); + } else { + blockedURLs.add(baseURL); + } + } else if (contentCspEnabled && origin === "contentScript") { + if (inline) { + forbiddenURLs.add(baseURL); + } + } else { + expectedURLs.add(baseURL); + } + } + + if (cspEnabled) { + for (let { origin, css } of sources) { + if (origin === "page") { + blockedSources.add(css); + } + } + } + + return { expectedURLs, forbiddenURLs, blockedURLs, blockedSources }; +} + +/** + * Awaits the content loads for each of the given expected base URLs, + * and checks that their origin strings are as expected. Triggers a test + * failure if any of the given forbidden URLs is requested. + * + * @param {Promise<object>} urlsPromise + * A promise which resolves to an object containing expected and + * forbidden URL sets, as returned by {@see computeBaseURLs}. + * @param {object<string, string>} origins + * A mapping of origin parameters as they appear in URL query + * strings to the origin strings returned by corresponding + * principals. These values are used to test requests against + * their expected origins. + * @returns {Promise} + * A promise which resolves when all requests have been + * processed. + */ +function awaitLoads(urlsPromise, origins) { + return new Promise(resolve => { + let expectedURLs, forbiddenURLs; + let queuedChannels = []; + + let observer; + + function checkChannel(channel) { + let origURL = channel.URI.spec; + let { baseURL, origin } = getOriginBase(origURL); + + if (forbiddenURLs.has(baseURL)) { + ok(false, `Got unexpected request for forbidden URL ${origURL}`); + } + + if (expectedURLs.has(baseURL)) { + expectedURLs.delete(baseURL); + + equal( + channel.loadInfo.triggeringPrincipal.origin, + origins[origin], + `Got expected origin for URL ${origURL}` + ); + + if (!expectedURLs.size) { + Services.obs.removeObserver(observer, "http-on-modify-request"); + info("Got all expected requests"); + resolve(); + } + } + } + + urlsPromise.then(urls => { + expectedURLs = new Set(urls.expectedURLs); + forbiddenURLs = new Set([...urls.forbiddenURLs, ...urls.blockedURLs]); + + for (let channel of queuedChannels.splice(0)) { + checkChannel(channel.QueryInterface(Ci.nsIChannel)); + } + }); + + observer = (channel, topic, data) => { + if (expectedURLs) { + checkChannel(channel.QueryInterface(Ci.nsIChannel)); + } else { + queuedChannels.push(channel); + } + }; + Services.obs.addObserver(observer, "http-on-modify-request"); + }); +} + +function readUTF8InputStream(stream) { + let buffer = NetUtil.readInputStream(stream, stream.available()); + return new TextDecoder().decode(buffer); +} + +/** + * Awaits CSP reports for each of the given forbidden base URLs. + * Triggers a test failure if any of the given expected URLs triggers a + * report. + * + * @param {Promise<object>} urlsPromise + * A promise which resolves to an object containing expected and + * forbidden URL sets, as returned by {@see computeBaseURLs}. + * @returns {Promise} + * A promise which resolves when all requests have been + * processed. + */ +function awaitCSP(urlsPromise) { + return new Promise(resolve => { + let expectedURLs, blockedURLs, blockedSources; + let queuedRequests = []; + + function checkRequest(request) { + let body = JSON.parse(readUTF8InputStream(request.bodyInputStream)); + let report = body["csp-report"]; + + let origURL = report["blocked-uri"]; + if (origURL !== "inline" && origURL !== "") { + let { baseURL } = getOriginBase(origURL); + + if (expectedURLs.has(baseURL)) { + ok(false, `Got unexpected CSP report for allowed URL ${origURL}`); + } + + if (blockedURLs.has(baseURL)) { + blockedURLs.delete(baseURL); + + ok(true, `Got CSP report for forbidden URL ${origURL}`); + } + } + + let source = report["script-sample"]; + if (source) { + if (blockedSources.has(source)) { + blockedSources.delete(source); + + ok( + true, + `Got CSP report for forbidden inline source ${JSON.stringify( + source + )}` + ); + } + } + + if (!blockedURLs.size && !blockedSources.size) { + ok(true, "Got all expected CSP reports"); + resolve(); + } + } + + urlsPromise.then(urls => { + blockedURLs = new Set(urls.blockedURLs); + blockedSources = new Set(urls.blockedSources); + ({ expectedURLs } = urls); + + for (let request of queuedRequests.splice(0)) { + checkRequest(request); + } + }); + + server.registerPathHandler(CSP_REPORT_PATH, (request, response) => { + response.setStatusLine(request.httpVersion, 204, "No Content"); + + if (expectedURLs) { + checkRequest(request); + } else { + queuedRequests.push(request); + } + }); + }); +} + +/** + * A list of tests to run in each context, as understood by + * {@see getElementData}. + */ +const TESTS = [ + { + element: ["audio", {}], + src: "audio.webm", + }, + { + element: ["audio", {}, ["source", {}]], + src: "audio-source.webm", + }, + // TODO: <frame> element, which requires a frameset document. + { + // the blocked-uri for frame-navigations is the pre-path URI. For the + // purpose of this test we do not strip the blocked-uri by setting the + // preference 'truncate_blocked_uri_for_frame_navigations' + element: ["iframe", {}], + src: "iframe.html", + }, + { + element: ["img", {}], + src: "img.png", + }, + { + element: ["img", {}], + src: "imgset.png", + srcAttr: "srcset", + }, + { + element: ["input", { type: "image" }], + src: "input.png", + }, + { + element: ["link", { rel: "stylesheet" }], + src: "link.css", + srcAttr: "href", + }, + { + element: ["picture", {}, ["source", {}], ["img", {}]], + src: "picture.png", + srcAttr: "srcset", + }, + { + element: ["script", {}], + src: "script.js", + liveSrc: false, + }, + { + element: ["video", {}], + src: "video.webm", + }, + { + element: ["video", {}, ["source", {}]], + src: "video-source.webm", + }, +]; + +for (let test of TESTS) { + if (!test.srcAttr) { + test.srcAttr = "src"; + } + if (!("liveSrc" in test)) { + test.liveSrc = true; + } +} + +/** + * A set of sources for which each of the above tests is expected to + * generate one request, if each of the properties in the value object + * matches the value of the same property in the test object. + */ +// Sources which load with the page context. +const PAGE_SOURCES = { + "contentScript-content-attr-after-inject": { liveSrc: true }, + "contentScript-content-change-after-inject": { liveSrc: true }, + "contentScript-inject-after-content-attr": {}, + "contentScript-relative-url": {}, + pageHTML: {}, + pageScript: {}, + "pageScript-attr-after-inject": {}, + "pageScript-prop": {}, + "pageScript-prop-after-inject": {}, + "pageScript-relative-url": {}, +}; +// Sources which load with the extension context. +const EXTENSION_SOURCES = { + contentScript: {}, + "contentScript-attr-after-inject": { liveSrc: true }, + "contentScript-content-inject-after-attr": {}, + "contentScript-prop": {}, + "contentScript-prop-after-inject": {}, +}; +// When our default content script CSP is applied, only +// liveSrc: true are loading. IOW, the "script" test above +// will fail. +const EXTENSION_SOURCES_CONTENT_CSP = { + contentScript: { liveSrc: true }, + "contentScript-attr-after-inject": { liveSrc: true }, + "contentScript-content-inject-after-attr": { liveSrc: true }, + "contentScript-prop": { liveSrc: true }, + "contentScript-prop-after-inject": { liveSrc: true }, +}; +// All sources. +const SOURCES = Object.assign({}, PAGE_SOURCES, EXTENSION_SOURCES); + +registerStaticPage( + "/page.html", + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + <script nonce="deadbeef"> + ${getInjectionScript(TESTS, { source: "pageScript", origin: "page" })} + </script> + </head> + <body> + ${TESTS.map(test => + toHTML(test, { source: "pageHTML", origin: "page" }) + ).join("\n ")} + </body> + </html>` +); + +function catchViolation() { + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("securitypolicyviolation", e => { + browser.test.assertTrue( + e.documentURI !== "moz-extension", + `securitypolicyviolation: ${e.violatedDirective} ${e.documentURI}` + ); + }); +} + +const EXTENSION_DATA = { + manifest: { + content_scripts: [ + { + matches: ["http://*/page.html"], + run_at: "document_start", + js: ["violation.js", "content_script.js"], + }, + ], + }, + + files: { + "violation.js": catchViolation, + "content_script.js": getInjectionScript(TESTS, { + source: "contentScript", + origin: "contentScript", + }), + }, +}; + +const pageURL = `${BASE_URL}/page.html`; +const pageURI = Services.io.newURI(pageURL); + +// Merges the sets of expected URL and source data returned by separate +// computedExpectedForbiddenURLs and computedBaseURLs calls. +function mergeSources(a, b) { + return { + expectedURLs: new Set([...a.expectedURLs, ...b.expectedURLs]), + forbiddenURLs: new Set([...a.forbiddenURLs, ...b.forbiddenURLs]), + blockedURLs: new Set([...a.blockedURLs, ...b.blockedURLs]), + blockedSources: a.blockedSources || b.blockedSources, + }; +} + +// Returns a set of origin strings for the given extension and content page, for +// use in verifying request triggering principals. +function getOrigins(extension) { + return { + page: Services.scriptSecurityManager.createContentPrincipal(pageURI, {}) + .origin, + contentScript: Cu.getObjectPrincipal( + Cu.Sandbox([extension.principal, pageURL]) + ).origin, + extension: extension.principal.origin, + }; +} + +/** + * Tests that various types of inline content elements initiate requests + * with the triggering pringipal of the caller that requested the load. + */ +add_task(async function test_contentscript_triggeringPrincipals() { + let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg), + computeBaseURLs(TESTS, SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + let finished = awaitLoads(urlsPromise, origins); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); + + clearCache(); +}); + +/** + * Tests that the correct CSP is applied to loads of inline content + * depending on whether the load was initiated by an extension or the + * content page. + */ +add_task(async function test_contentscript_csp() { + // TODO bug 1408193: We currently don't get the full set of CSP reports when + // running in network scheduling chaos mode. It's not entirely clear why. + let chaosMode = parseInt(env.get("MOZ_CHAOSMODE"), 16); + let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02); + + gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`; + + let extension = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg, true), + computeBaseURLs(TESTS, EXTENSION_SOURCES, PAGE_SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + + let finished = Promise.all([ + awaitLoads(urlsPromise, origins), + checkCSPReports && awaitCSP(urlsPromise), + ]); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); +}); + +/** + * Tests that the correct CSP is applied to loads of inline content + * depending on whether the load was initiated by an extension or the + * content page. + */ +add_task(async function test_extension_contentscript_csp() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + + // TODO bug 1408193: We currently don't get the full set of CSP reports when + // running in network scheduling chaos mode. It's not entirely clear why. + let chaosMode = parseInt(env.get("MOZ_CHAOSMODE"), 16); + let checkCSPReports = !(chaosMode === 0 || chaosMode & 0x02); + + gContentSecurityPolicy = `default-src 'none' 'report-sample'; script-src 'nonce-deadbeef' 'unsafe-eval' 'report-sample'; report-uri ${CSP_REPORT_PATH};`; + + let data = { + ...EXTENSION_DATA, + manifest: { + ...EXTENSION_DATA.manifest, + manifest_version: 3, + }, + }; + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + + let urlsPromise = extension.awaitMessage("css-sources").then(msg => { + return mergeSources( + computeExpectedForbiddenURLs(msg, true, true), + computeBaseURLs(TESTS, EXTENSION_SOURCES_CONTENT_CSP, PAGE_SOURCES) + ); + }); + + let origins = getOrigins(extension.extension); + + let finished = Promise.all([ + awaitLoads(urlsPromise, origins), + checkCSPReports && awaitCSP(urlsPromise), + ]); + + let contentPage = await ExtensionTestUtils.loadContentPage(pageURL); + + await finished; + + await extension.unload(); + await contentPage.close(); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js new file mode 100644 index 0000000000..dd3ab7846d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_unregister_during_loadContentScript.js @@ -0,0 +1,91 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function content_script_unregistered_during_loadContentScript() { + let content_scripts = []; + + for (let i = 0; i < 10; i++) { + content_scripts.push({ + matches: ["<all_urls>"], + js: ["dummy.js"], + run_at: "document_start", + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts, + }, + files: { + "dummy.js": function() { + browser.test.sendMessage("content-script-executed"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + info("Wait for all the content scripts to be executed"); + await Promise.all( + content_scripts.map(() => extension.awaitMessage("content-script-executed")) + ); + + const promiseDone = contentPage.spawn([extension.id], extensionId => { + const { ExtensionProcessScript } = ChromeUtils.import( + "resource://gre/modules/ExtensionProcessScript.jsm" + ); + + return new Promise(resolve => { + // This recreates a scenario similar to Bug 1593240 and ensures that the + // related fix doesn't regress. Replacing loadContentScript with a + // function that unregisters all the content scripts make us sure that + // mutating the policy contentScripts doesn't trigger a crash due to + // the invalidation of the contentScripts iterator being used by the + // caller (ExtensionPolicyService::CheckContentScripts). + const { loadContentScript } = ExtensionProcessScript; + ExtensionProcessScript.loadContentScript = async (...args) => { + const policy = WebExtensionPolicy.getByID(extensionId); + let initial = policy.contentScripts.length; + let i = initial; + while (i) { + policy.unregisterContentScript(policy.contentScripts[--i]); + } + Services.tm.dispatchToMainThread(() => + resolve({ + initial, + final: policy.contentScripts.length, + }) + ); + // Call the real loadContentScript method. + return loadContentScript(...args); + }; + }); + }); + + info("Reload the webpage"); + await contentPage.loadURL(`${BASE_URL}/file_sample.html`); + info("Wait for all the content scripts to be executed again"); + await Promise.all( + content_scripts.map(() => extension.awaitMessage("content-script-executed")) + ); + info("No crash triggered as expected"); + + Assert.deepEqual( + await promiseDone, + { initial: content_scripts.length, final: 0 }, + "All content scripts unregistered as expected" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js new file mode 100644 index 0000000000..83cb2f86e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xml_prettyprint.js @@ -0,0 +1,75 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Services.prefs.setBoolPref("layout.xml.prettyprint", true); + +const BASE_XML = '<?xml version="1.0" encoding="UTF-8"?>'; +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/test.xml", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/xml; charset=utf-8", false); + response.write(`${BASE_XML}\n<note></note>`); +}); + +// Make sure that XML pretty printer runs after content scripts +// that runs at document_start (See Bug 1605657). +add_task(async function content_script_on_xml_prettyprinted_document() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["<all_urls>"], + js: ["start.js"], + run_at: "document_start", + }, + ], + }, + files: { + "start.js": async function() { + const el = document.createElement("ext-el"); + document.documentElement.append(el); + if (document.readyState !== "complete") { + await new Promise(resolve => { + document.addEventListener("DOMContentLoaded", resolve, { + once: true, + }); + }); + } + browser.test.sendMessage("content-script-done"); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/test.xml" + ); + + info("Wait content script and xml document to be fully loaded"); + await extension.awaitMessage("content-script-done"); + + info("Verify the xml file is still pretty printed"); + const res = await contentPage.spawn([], () => { + const doc = this.content.document; + const shadowRoot = doc.documentElement.openOrClosedShadowRoot; + const prettyPrintLink = + shadowRoot && + shadowRoot.querySelector("link[href*='XMLPrettyPrint.css']"); + return { + hasShadowRoot: !!shadowRoot, + hasPrettyPrintLink: !!prettyPrintLink, + }; + }); + + Assert.deepEqual( + res, + { hasShadowRoot: true, hasPrettyPrintLink: true }, + "The XML file has the pretty print shadowRoot" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js new file mode 100644 index 0000000000..cab508b040 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xorigin_frame.js @@ -0,0 +1,85 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.net", "example.org"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_process_switch_cross_origin_frame() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.org/*/file_iframe.html"], + all_frames: true, + js: ["cs.js"], + }, + ], + }, + + background() { + browser.runtime.onConnect.addListener(port => { + port.onMessage.addListener(async () => { + let { url, frameId } = port.sender; + + browser.test.assertTrue(frameId > 0, "sender frameId is ok"); + browser.test.assertTrue( + url.endsWith("file_iframe.html"), + "url is ok" + ); + + port.postMessage(frameId); + port.disconnect(); + }); + }); + }, + + files: { + "cs.js"() { + browser.test.assertEq( + location.href, + "http://example.org/data/file_iframe.html", + "url is ok" + ); + + let frameId; + let port = browser.runtime.connect(); + port.onMessage.addListener(response => { + frameId = response; + }); + port.onDisconnect.addListener(() => { + browser.test.sendMessage("content-script-loaded", frameId); + }); + port.postMessage("hello"); + }, + }, + }); + + await extension.startup(); + + const contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.net/data/file_with_xorigin_frame.html" + ); + + const browserProcessId = + contentPage.browser.browsingContext.currentWindowGlobal.domProcess.childID; + + const scriptFrameId = await extension.awaitMessage("content-script-loaded"); + + const children = contentPage.browser.browsingContext.children.map(bc => ({ + browsingContextId: bc.id, + processId: bc.currentWindowGlobal.domProcess.childID, + })); + + Assert.equal(children.length, 1); + Assert.equal(scriptFrameId, children[0].browsingContextId); + + if (contentPage.remoteSubframes) { + Assert.notEqual(browserProcessId, children[0].processId); + } else { + Assert.equal(browserProcessId, children[0].processId); + } + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js new file mode 100644 index 0000000000..7b92d5c4b7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contentscript_xrays.js @@ -0,0 +1,59 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_xrays() { + async function contentScript() { + let unwrapped = window.wrappedJSObject; + + browser.test.assertEq( + "undefined", + typeof test, + "Should not have named X-ray property access" + ); + browser.test.assertEq( + undefined, + window.test, + "Should not have named X-ray property access" + ); + browser.test.assertEq( + "object", + typeof unwrapped.test, + "Should always have non-X-ray named property access" + ); + + browser.test.notifyPass("contentScriptXrays"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish("contentScriptXrays"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js new file mode 100644 index 0000000000..7c06fe33a5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts.js @@ -0,0 +1,198 @@ +"use strict"; + +const global = this; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); + +var { BaseContext, EventManager } = ExtensionCommon; + +class StubContext extends BaseContext { + constructor() { + let fakeExtension = { id: "test@web.extension" }; + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + logActivity(type, name, data) { + // no-op required by subclass + } + + get cloneScope() { + return this.sandbox; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +add_task(async function test_post_unload_promises() { + let context = new StubContext(); + + let fail = result => { + ok(false, `Unexpected callback: ${result}`); + }; + + // Make sure promises resolve normally prior to unload. + let promises = [ + context.wrapPromise(Promise.resolve()), + context.wrapPromise(Promise.reject({ message: "" })).catch(() => {}), + ]; + + await Promise.all(promises); + + // Make sure promises that resolve after unload do not trigger + // resolution handlers. + + context.wrapPromise(Promise.resolve("resolved")).then(fail); + + context.wrapPromise(Promise.reject({ message: "rejected" })).then(fail, fail); + + context.unload(); + + // The `setTimeout` ensures that we return to the event loop after + // promise resolution, which means we're guaranteed to return after + // any micro-tasks that get enqueued by the resolution handlers above. + await new Promise(resolve => setTimeout(resolve, 0)); +}); + +add_task(async function test_post_unload_listeners() { + let context = new StubContext(); + + let fire; + let manager = new EventManager({ + context, + name: "EventManager", + register: _fire => { + fire = () => { + _fire.async(); + }; + return () => {}; + }, + }); + + let fail = event => { + ok(false, `Unexpected event: ${event}`); + }; + + // Check that event listeners isn't called after it has been removed. + manager.addListener(fail); + + let promise = new Promise(resolve => manager.addListener(resolve)); + + fire(); + + // The `fireSingleton` call ia dispatched asynchronously, so it won't + // have fired by this point. The `fail` listener that we remove now + // should not be called, even though the event has already been + // enqueued. + manager.removeListener(fail); + + // Wait for the remaining listener to be called, which should always + // happen after the `fail` listener would normally be called. + await promise; + + // Check that the event listener isn't called after the context has + // unloaded. + manager.addListener(fail); + + // The `fire` callback always dispatches events + // asynchronously, so we need to test that any pending event callbacks + // aren't fired after the context unloads. We also need to test that + // any `fire` calls that happen *after* the context is unloaded also + // do not trigger callbacks. + fire(); + Promise.resolve().then(fire); + + context.unload(); + + // The `setTimeout` ensures that we return to the event loop after + // promise resolution, which means we're guaranteed to return after + // any micro-tasks that get enqueued by the resolution handlers above. + await new Promise(resolve => setTimeout(resolve, 0)); +}); + +class Context extends BaseContext { + constructor(principal) { + let fakeExtension = { id: "test@web.extension" }; + super("testEnv", fakeExtension); + Object.defineProperty(this, "principal", { + value: principal, + configurable: true, + }); + this.sandbox = Cu.Sandbox(principal, { wantXrays: false }); + } + + logActivity(type, name, data) { + // no-op required by subclass + } + + get cloneScope() { + return this.sandbox; + } +} + +let ssm = Services.scriptSecurityManager; +const PRINCIPAL1 = ssm.createContentPrincipalFromOrigin( + "http://www.example.org" +); +const PRINCIPAL2 = ssm.createContentPrincipalFromOrigin( + "http://www.somethingelse.org" +); + +// Test that toJSON() works in the json sandbox +add_task(async function test_stringify_toJSON() { + let context = new Context(PRINCIPAL1); + let obj = Cu.evalInSandbox( + "({hidden: true, toJSON() { return {visible: true}; } })", + context.sandbox + ); + + let stringified = context.jsonStringify(obj); + let expected = JSON.stringify({ visible: true }); + equal( + stringified, + expected, + "Stringified object with toJSON() method is as expected" + ); +}); + +// Test that stringifying in inaccessible property throws +add_task(async function test_stringify_inaccessible() { + let context = new Context(PRINCIPAL1); + let sandbox = context.sandbox; + let sandbox2 = Cu.Sandbox(PRINCIPAL2); + + Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox( + "({ subobject: true })", + sandbox2 + ); + let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox); + Assert.throws(() => { + context.jsonStringify(obj); + }, /Permission denied to access property "toJSON"/); +}); + +add_task(async function test_stringify_accessible() { + // Test that an accessible property from another global is included + let principal = Cu.getObjectPrincipal(Cu.Sandbox([PRINCIPAL1, PRINCIPAL2])); + let context = new Context(principal); + let sandbox = context.sandbox; + let sandbox2 = Cu.Sandbox(PRINCIPAL2); + + Cu.waiveXrays(sandbox).subobj = Cu.evalInSandbox( + "({ subobject: true })", + sandbox2 + ); + let obj = Cu.evalInSandbox("({ local: true, nested: subobj })", sandbox); + let stringified = context.jsonStringify(obj); + + let expected = JSON.stringify({ local: true, nested: { subobject: true } }); + equal( + stringified, + expected, + "Stringified object with accessible property is as expected" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js new file mode 100644 index 0000000000..521b7db4e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contexts_gc.js @@ -0,0 +1,273 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// Each of these tests do the following: +// 1. Load document to create an extension context (instance of BaseContext). +// 2. Get weak reference to that context. +// 3. Unload the document. +// 4. Force GC and check that the weak reference has been invalidated. + +async function reloadTopContext(contentPage) { + await contentPage.spawn(null, async () => { + let { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" + ); + let windowNukeObserved = TestUtils.topicObserved("inner-window-nuked"); + info(`Reloading top-level document`); + this.content.location.reload(); + await windowNukeObserved; + info(`Reloaded top-level document`); + }); +} + +async function assertContextReleased(contentPage, description) { + await contentPage.spawn(description, async assertionDescription => { + // Force GC, see https://searchfox.org/mozilla-central/rev/b0275bc977ad7fda615ef34b822bba938f2b16fd/testing/talos/talos/tests/devtools/addon/content/damp.js#84-98 + // and https://searchfox.org/mozilla-central/rev/33c21c060b7f3a52477a73d06ebcb2bf313c4431/xpcom/base/nsMemoryReporterManager.cpp#2574-2585,2591-2594 + let gcCount = 0; + while (gcCount < 30 && this.contextWeakRef.get() !== null) { + ++gcCount; + // The JS engine will sometimes hold IC stubs for function + // environments alive across multiple CCs, which can keep + // closed-over JS objects alive. A shrinking GC will throw those + // stubs away, and therefore side-step the problem. + Cu.forceShrinkingGC(); + Cu.forceCC(); + Cu.forceGC(); + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + } + + // The above loop needs to be repeated at most 3 times according to MinimizeMemoryUsage: + // https://searchfox.org/mozilla-central/rev/6f86cc3479f80ace97f62634e2c82a483d1ede40/xpcom/base/nsMemoryReporterManager.cpp#2644-2647 + Assert.lessOrEqual( + gcCount, + 3, + `Context should have been GCd within a few GC attempts.` + ); + + // Each test will set this.contextWeakRef before unloading the document. + Assert.ok(!this.contextWeakRef.get(), assertionDescription); + }); +} + +add_task(async function test_ContentScriptContextChild_in_child_frame() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_iframe.html"], + js: ["content_script.js"], + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": "browser.test.sendMessage('contentScriptLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_toplevel.html` + ); + await extension.awaitMessage("contentScriptLoaded"); + + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + let frame = this.content.document.querySelector( + "iframe[src*='file_iframe.html']" + ); + let context = DocumentManager.getContext(extensionId, frame.contentWindow); + + Assert.ok(context, "Got content script context"); + + this.contextWeakRef = Cu.getWeakReference(context); + frame.remove(); + }); + + await assertContextReleased( + contentPage, + "ContentScriptContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ContentScriptContextChild_in_toplevel() { + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + all_frames: true, + }, + ], + }, + + files: { + "content_script.js": "browser.test.sendMessage('contentScriptLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension.awaitMessage("contentScriptLoaded"); + + await contentPage.spawn(extension.id, async extensionId => { + let { DocumentManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionContent.jsm", + null + ); + let context = DocumentManager.getContext(extensionId, this.content); + + Assert.ok(context, "Got content script context"); + + this.contextWeakRef = Cu.getWeakReference(context); + }); + + await reloadTopContext(contentPage); + await extension.awaitMessage("contentScriptLoaded"); + await assertContextReleased( + contentPage, + "ContentScriptContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ExtensionPageContextChild_in_child_frame() { + let extensionData = { + files: { + "iframe.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "toplevel.html": ` + <!DOCTYPE html><meta charset="utf8"> + <iframe src="iframe.html"></iframe> + `, + "script.js": "browser.test.sendMessage('extensionPageLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("extensionPageLoaded"); + + await contentPage.spawn(extension.id, async extensionId => { + let { ExtensionPageChild } = ChromeUtils.import( + "resource://gre/modules/ExtensionPageChild.jsm" + ); + + let frame = this.content.document.querySelector( + "iframe[src*='iframe.html']" + ); + let innerWindowID = + frame.browsingContext.currentWindowContext.innerWindowId; + let context = ExtensionPageChild.extensionContexts.get(innerWindowID); + + Assert.ok(context, "Got extension page context for child frame"); + + this.contextWeakRef = Cu.getWeakReference(context); + frame.remove(); + }); + + await assertContextReleased( + contentPage, + "ExtensionPageContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_ExtensionPageContextChild_in_toplevel() { + let extensionData = { + files: { + "toplevel.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "script.js": "browser.test.sendMessage('extensionPageLoaded');", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("extensionPageLoaded"); + + await contentPage.spawn(extension.id, async extensionId => { + let { ExtensionPageChild } = ChromeUtils.import( + "resource://gre/modules/ExtensionPageChild.jsm" + ); + + let innerWindowID = this.content.windowGlobalChild.innerWindowId; + let context = ExtensionPageChild.extensionContexts.get(innerWindowID); + + Assert.ok(context, "Got extension page context for top-level document"); + + this.contextWeakRef = Cu.getWeakReference(context); + }); + + await reloadTopContext(contentPage); + await extension.awaitMessage("extensionPageLoaded"); + // For some unknown reason, the context cannot forcidbly be released by the + // garbage collector unless we wait for a short while. + await contentPage.spawn(null, async () => { + let start = Date.now(); + // The treshold was found after running this subtest only, 300 times + // in a release build (100 of xpcshell, xpcshell-e10s and xpcshell-remote). + // With treshold 8, almost half of the tests complete after a 17-18 ms delay. + // With treshold 7, over half of the tests complete after a 13-14 ms delay, + // with 12 failures in 300 tests runs. + // Let's double that number to have a safety margin. + for (let i = 0; i < 15; ++i) { + await new Promise(resolve => this.content.setTimeout(resolve, 0)); + } + info(`Going to GC after waiting for ${Date.now() - start} ms.`); + }); + await assertContextReleased( + contentPage, + "ExtensionPageContextChild should have been released" + ); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js new file mode 100644 index 0000000000..1c1827c64f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_contextual_identities.js @@ -0,0 +1,513 @@ +"use strict"; + +do_get_profile(); + +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); + +const CONTAINERS_PREF = "privacy.userContext.enabled"; + +AddonTestUtils.init(this); + +add_task(async function startup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contextualIdentities_without_permissions() { + function background() { + browser.test.assertTrue( + !browser.contextualIdentities, + "contextualIdentities API is not available when the contextualIdentities permission is not required" + ); + browser.test.notifyPass("contextualIdentities_without_permission"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + applications: { + gecko: { id: "testing@thing.com" }, + }, + permissions: [], + }, + }); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities_without_permission"); + await extension.unload(); +}); + +add_task(async function test_contextualIdentity_events() { + async function background() { + function createOneTimeListener(type) { + return new Promise((resolve, reject) => { + try { + browser.test.assertTrue( + type in browser.contextualIdentities, + `Found API object browser.contextualIdentities.${type}` + ); + const listener = change => { + browser.test.assertTrue( + "contextualIdentity" in change, + `Found identity in change` + ); + browser.contextualIdentities[type].removeListener(listener); + resolve(change); + }; + browser.contextualIdentities[type].addListener(listener); + } catch (e) { + reject(e); + } + }); + } + + function assertExpected(expected, container) { + // Number of keys that are added by the APIs + const createdCount = 2; + for (let key of Object.keys(container)) { + browser.test.assertTrue(key in expected, `found property ${key}`); + browser.test.assertEq( + expected[key], + container[key], + `property value for ${key} is correct` + ); + } + const hexMatch = /^#[0-9a-f]{6}$/; + browser.test.assertTrue( + hexMatch.test(expected.colorCode), + "Color code property was expected Hex shape" + ); + const iconMatch = /^resource:\/\/usercontext-content\/[a-z]+[.]svg$/; + browser.test.assertTrue( + iconMatch.test(expected.iconUrl), + "Icon url property was expected shape" + ); + browser.test.assertEq( + Object.keys(expected).length, + Object.keys(container).length + createdCount, + "all expected properties found" + ); + } + + let onCreatePromise = createOneTimeListener("onCreated"); + + let containerObj = { name: "foobar", color: "red", icon: "circle" }; + let ci = await browser.contextualIdentities.create(containerObj); + browser.test.assertTrue(!!ci, "We have an identity"); + const onCreateListenerResponse = await onCreatePromise; + const cookieStoreId = ci.cookieStoreId; + assertExpected( + onCreateListenerResponse.contextualIdentity, + Object.assign(containerObj, { cookieStoreId }) + ); + + let onUpdatedPromise = createOneTimeListener("onUpdated"); + let updateContainerObj = { name: "testing", color: "blue", icon: "dollar" }; + ci = await browser.contextualIdentities.update( + cookieStoreId, + updateContainerObj + ); + browser.test.assertTrue(!!ci, "We have an update identity"); + const onUpdatedListenerResponse = await onUpdatedPromise; + assertExpected( + onUpdatedListenerResponse.contextualIdentity, + Object.assign(updateContainerObj, { cookieStoreId }) + ); + + let onRemovePromise = createOneTimeListener("onRemoved"); + ci = await browser.contextualIdentities.remove( + updateContainerObj.cookieStoreId + ); + browser.test.assertTrue(!!ci, "We have an remove identity"); + const onRemoveListenerResponse = await onRemovePromise; + assertExpected( + onRemoveListenerResponse.contextualIdentity, + Object.assign(updateContainerObj, { cookieStoreId }) + ); + + browser.test.notifyPass("contextualIdentities_events"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + useAddonManager: "temporary", + manifest: { + applications: { + gecko: { id: "testing@thing.com" }, + }, + permissions: ["contextualIdentities"], + }, + }); + + Services.prefs.setBoolPref(CONTAINERS_PREF, true); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities_events"); + await extension.unload(); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_with_permissions() { + const initial = Services.prefs.getBoolPref(CONTAINERS_PREF); + + async function background() { + let ci; + await browser.test.assertRejects( + browser.contextualIdentities.get("foobar"), + "Invalid contextual identity: foobar", + "API should reject here" + ); + await browser.test.assertRejects( + browser.contextualIdentities.update("foobar", { name: "testing" }), + "Invalid contextual identity: foobar", + "API should reject for unknown updates" + ); + await browser.test.assertRejects( + browser.contextualIdentities.remove("foobar"), + "Invalid contextual identity: foobar", + "API should reject for removing unknown containers" + ); + + ci = await browser.contextualIdentities.get("firefox-container-1"); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertTrue("name" in ci, "We have an identity.name"); + browser.test.assertTrue("color" in ci, "We have an identity.color"); + browser.test.assertTrue("icon" in ci, "We have an identity.icon"); + browser.test.assertEq("Personal", ci.name, "identity.name is correct"); + browser.test.assertEq( + "firefox-container-1", + ci.cookieStoreId, + "identity.cookieStoreId is correct" + ); + + function listenForMessage(messageName, stateChangeBool) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg) { + browser.test.log(`Got message from background: ${msg}`); + if (msg === messageName + "-response") { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }); + browser.test.log( + `Sending message to background: ${messageName} ${stateChangeBool}` + ); + browser.test.sendMessage(messageName, stateChangeBool); + }); + } + + await listenForMessage("containers-state-change", false); + + browser.test.assertRejects( + browser.contextualIdentities.query({}), + "Contextual identities are currently disabled", + "Throws when containers are disabled" + ); + + await listenForMessage("containers-state-change", true); + + let cis = await browser.contextualIdentities.query({}); + browser.test.assertEq( + 4, + cis.length, + "by default we should have 4 containers" + ); + + cis = await browser.contextualIdentities.query({ name: "Personal" }); + browser.test.assertEq( + 1, + cis.length, + "by default we should have 1 container called Personal" + ); + + cis = await browser.contextualIdentities.query({ name: "foobar" }); + browser.test.assertEq( + 0, + cis.length, + "by default we should have 0 container called foobar" + ); + + ci = await browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "gift", + }); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("foobar", ci.name, "identity.name is correct"); + browser.test.assertEq("red", ci.color, "identity.color is correct"); + browser.test.assertEq("gift", ci.icon, "identity.icon is correct"); + browser.test.assertTrue( + !!ci.cookieStoreId, + "identity.cookieStoreId is correct" + ); + + browser.test.assertRejects( + browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "firefox", + }), + "Invalid icon firefox for container", + "Create container called with an invalid icon" + ); + + browser.test.assertRejects( + browser.contextualIdentities.create({ + name: "foobar", + color: "firefox-orange", + icon: "gift", + }), + "Invalid color name firefox-orange for container", + "Create container called with an invalid color" + ); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq( + 5, + cis.length, + "we should still have have 5 containers" + ); + + ci = await browser.contextualIdentities.get(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("foobar", ci.name, "identity.name is correct"); + browser.test.assertEq("red", ci.color, "identity.color is correct"); + browser.test.assertEq("gift", ci.icon, "identity.icon is correct"); + + browser.test.assertRejects( + browser.contextualIdentities.update(ci.cookieStoreId, { + name: "foobar", + color: "red", + icon: "firefox", + }), + "Invalid icon firefox for container", + "Create container called with an invalid icon" + ); + + browser.test.assertRejects( + browser.contextualIdentities.update(ci.cookieStoreId, { + name: "foobar", + color: "firefox-orange", + icon: "gift", + }), + "Invalid color name firefox-orange for container", + "Create container called with an invalid color" + ); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(5, cis.length, "now we have 5 identities"); + + ci = await browser.contextualIdentities.update(ci.cookieStoreId, { + name: "barfoo", + color: "blue", + icon: "cart", + }); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + ci = await browser.contextualIdentities.get(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + ci = await browser.contextualIdentities.remove(ci.cookieStoreId); + browser.test.assertTrue(!!ci, "We have an identity"); + browser.test.assertEq("barfoo", ci.name, "identity.name is correct"); + browser.test.assertEq("blue", ci.color, "identity.color is correct"); + browser.test.assertEq("cart", ci.icon, "identity.icon is correct"); + + cis = await browser.contextualIdentities.query({}); + browser.test.assertEq(4, cis.length, "we are back to 4 identities"); + + browser.test.notifyPass("contextualIdentities"); + } + + function makeExtension(id) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + applications: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + + let extension = makeExtension("containers-test@mozilla.org"); + + extension.onMessage("containers-state-change", stateBool => { + Cu.reportError(`Got message "containers-state-change", ${stateBool}`); + Services.prefs.setBoolPref(CONTAINERS_PREF, stateBool); + Cu.reportError("Changed pref"); + extension.sendMessage("containers-state-change-response"); + }); + + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + await extension.unload(); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + initial, + "Pref should now be initial state" + ); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_extensions_enable_containers() { + const initial = Services.prefs.getBoolPref(CONTAINERS_PREF); + async function background() { + let ci = await browser.contextualIdentities.get("firefox-container-1"); + browser.test.assertTrue(!!ci, "We have an identity"); + + browser.test.notifyPass("contextualIdentities"); + } + function makeExtension(id) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + applications: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + async function testSetting(expect, message) { + let setting = await ExtensionPreferencesManager.getSetting( + "privacy.containers" + ); + if (expect === null) { + equal(setting, null, message); + } else { + equal(setting.value, expect, message); + } + } + function testPref(expect, message) { + equal(Services.prefs.getBoolPref(CONTAINERS_PREF), expect, message); + } + + let extension = makeExtension("containers-test@mozilla.org"); + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + await extension.unload(); + await testSetting(null, "setting should be unset"); + testPref(initial, "setting should be initial value"); + + // Lets set containers explicitly to be off and test we keep it that way after removal + Services.prefs.setBoolPref(CONTAINERS_PREF, false); + + let extension1 = makeExtension("containers-test-1@mozilla.org"); + await extension1.startup(); + await extension1.awaitFinish("contextualIdentities"); + await testSetting(extension1.id, "setting should be controlled"); + testPref(true, "Pref should now be enabled, whatever it's initial state"); + + await extension1.unload(); + await testSetting(null, "setting should be unset"); + testPref(false, "Pref should be false"); + + // Lets set containers explicitly to be on and test we keep it that way after removal. + Services.prefs.setBoolPref(CONTAINERS_PREF, true); + + let extension2 = makeExtension("containers-test-2@mozilla.org"); + let extension3 = makeExtension("containers-test-3@mozilla.org"); + await extension2.startup(); + await extension2.awaitFinish("contextualIdentities"); + await extension3.startup(); + await extension3.awaitFinish("contextualIdentities"); + + // Flip the ordering to check it's still enabled + await testSetting(extension3.id, "setting should still be controlled by 3"); + testPref(true, "Pref should now be enabled 1"); + await extension3.unload(); + await testSetting(extension2.id, "setting should still be controlled by 2"); + testPref(true, "Pref should now be enabled 2"); + await extension2.unload(); + await testSetting(null, "setting should be unset"); + testPref(true, "Pref should now be enabled 3"); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); + +add_task(async function test_contextualIdentity_preference_change() { + async function background() { + let extensionInfo = await browser.management.getSelf(); + if (extensionInfo.version == "1.0.0") { + const containers = await browser.contextualIdentities.query({}); + browser.test.assertEq( + containers.length, + 4, + "We still have the original containers" + ); + await browser.contextualIdentities.create({ + name: "foobar", + color: "red", + icon: "circle", + }); + } + const containers = await browser.contextualIdentities.query({}); + browser.test.assertEq(containers.length, 5, "We have a new container"); + if (extensionInfo.version == "1.1.0") { + await browser.contextualIdentities.remove(containers[4].cookieStoreId); + } + browser.test.notifyPass("contextualIdentities"); + } + function makeExtension(id, version) { + return ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background, + manifest: { + version, + applications: { + gecko: { id }, + }, + permissions: ["contextualIdentities"], + }, + }); + } + + Services.prefs.setBoolPref(CONTAINERS_PREF, false); + let extension = makeExtension("containers-pref-test@mozilla.org", "1.0.0"); + await extension.startup(); + await extension.awaitFinish("contextualIdentities"); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + true, + "Pref should now be enabled, whatever it's initial state" + ); + + let extension2 = makeExtension("containers-pref-test@mozilla.org", "1.1.0"); + await extension2.startup(); + await extension2.awaitFinish("contextualIdentities"); + + await extension.unload(); + equal( + Services.prefs.getBoolPref(CONTAINERS_PREF), + false, + "Pref should now be the initial state we set it to." + ); + + Services.prefs.clearUserPref(CONTAINERS_PREF); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js new file mode 100644 index 0000000000..8edd61fc63 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookieBehaviors.js @@ -0,0 +1,675 @@ +"use strict"; + +const { UrlClassifierTestUtils } = ChromeUtils.import( + "resource://testing-common/UrlClassifierTestUtils.jsm" +); + +const { + // cookieBehavior constants. + BEHAVIOR_REJECT, + BEHAVIOR_REJECT_TRACKER, + + // lifetimePolicy constants. + ACCEPT_SESSION, +} = Ci.nsICookieService; + +function createPage({ script, body = "" } = {}) { + if (script) { + body += `<script src="${script}"></script>`; + } + + return `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + ${body} + </body> + </html>`; +} + +const server = createHttpServer({ hosts: ["example.com", "itisatracker.org"] }); +server.registerDirectory("/data/", do_get_file("data")); +server.registerPathHandler("/test-cookies", (request, response) => { + response.setHeader("Cache-Control", "no-cache", false); + response.setHeader("Content-Type", "text/json", false); + response.setHeader("Set-Cookie", "myKey=myCookie", true); + response.write('{"success": true}'); +}); +server.registerPathHandler("/subframe.html", (request, response) => { + response.write(createPage()); +}); +server.registerPathHandler("/page-with-tracker.html", (request, response) => { + response.write( + createPage({ + body: `<iframe src="http://itisatracker.org/test-cookies"></iframe>`, + }) + ); +}); +server.registerPathHandler("/sw.js", (request, response) => { + response.setHeader("Content-Type", "text/javascript", false); + response.write(""); +}); + +function assertCookiesForHost(url, cookiesCount, message) { + const { host } = new URL(url); + const cookies = Services.cookies.cookies.filter( + cookie => cookie.host === host + ); + equal(cookies.length, cookiesCount, message); + return cookies; +} + +// Test that the indexedDB and localStorage are allowed in an extension page +// and that the indexedDB is allowed in a extension worker. +add_task(async function test_ext_page_allowed_storage() { + function testWebStorages() { + const url = window.location.href; + + try { + // In a webpage accessing indexedDB throws on cookiesBehavior reject, + // here we verify that doesn't happen for an extension page. + browser.test.assertTrue( + indexedDB, + "IndexedDB global should be accessible" + ); + + // In a webpage localStorage is undefined on cookiesBehavior reject, + // here we verify that doesn't happen for an extension page. + browser.test.assertTrue( + localStorage, + "localStorage global should be defined" + ); + + const worker = new Worker("worker.js"); + worker.onmessage = event => { + browser.test.assertTrue( + event.data.pass, + "extension page worker have access to indexedDB" + ); + + browser.test.sendMessage("test-storage:done", url); + }; + + worker.postMessage({}); + } catch (err) { + browser.test.fail(`Unexpected error: ${err}`); + browser.test.sendMessage("test-storage:done", url); + } + } + + function testWorker() { + this.onmessage = () => { + try { + void indexedDB; + postMessage({ pass: true }); + } catch (err) { + postMessage({ pass: false }); + throw err; + } + }; + } + + async function createExtension() { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "test_web_storages.js": testWebStorages, + "worker.js": testWorker, + "page_subframe.html": createPage({ script: "test_web_storages.js" }), + "page_with_subframe.html": createPage({ + body: '<iframe src="page_subframe.html"></iframe>', + }), + "page.html": createPage({ + script: "test_web_storages.js", + }), + }, + }); + + await extension.startup(); + + const EXT_BASE_URL = `moz-extension://${extension.uuid}/`; + + return { extension, EXT_BASE_URL }; + } + + const cookieBehaviors = [ + "BEHAVIOR_LIMIT_FOREIGN", + "BEHAVIOR_REJECT_FOREIGN", + "BEHAVIOR_REJECT", + "BEHAVIOR_REJECT_TRACKER", + "BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN", + ]; + equal( + cookieBehaviors.length, + Ci.nsICookieService.BEHAVIOR_LAST, + "all behaviors should be covered" + ); + + for (const behavior of cookieBehaviors) { + info( + `Test extension page access to indexedDB & localStorage with ${behavior}` + ); + ok( + behavior in Ci.nsICookieService, + `${behavior} is a valid CookieBehavior` + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService[behavior] + ); + + // Create a new extension to ensure that the cookieBehavior just set is going to be + // used for the requests triggered by the extension page. + const { extension, EXT_BASE_URL } = await createExtension(); + const extPage = await ExtensionTestUtils.loadContentPage("about:blank", { + extension, + remote: extension.extension.remote, + }); + + info("Test from a top level extension page"); + await extPage.loadURL(`${EXT_BASE_URL}page.html`); + + let testedFromURL = await extension.awaitMessage("test-storage:done"); + equal( + testedFromURL, + `${EXT_BASE_URL}page.html`, + "Got the results from the expected url" + ); + + info("Test from a sub frame extension page"); + await extPage.loadURL(`${EXT_BASE_URL}page_with_subframe.html`); + + testedFromURL = await extension.awaitMessage("test-storage:done"); + equal( + testedFromURL, + `${EXT_BASE_URL}page_subframe.html`, + "Got the results from the expected url" + ); + + await extPage.close(); + await extension.unload(); + } +}); + +add_task(async function test_ext_page_3rdparty_cookies() { + // Disable tracking protection to test cookies on BEHAVIOR_REJECT_TRACKER + // (otherwise tracking protection would block the tracker iframe and + // we would not be actually checking the cookie behavior). + Services.prefs.setBoolPref("privacy.trackingprotection.enabled", false); + await UrlClassifierTestUtils.addTestTrackers(); + registerCleanupFunction(function() { + UrlClassifierTestUtils.cleanupTestTrackers(); + Services.prefs.clearUserPref("privacy.trackingprotection.enabled"); + Services.cookies.removeAll(); + }); + + function testRequestScript() { + browser.test.onMessage.addListener((msg, url) => { + const done = () => { + browser.test.sendMessage(`${msg}:done`); + }; + + switch (msg) { + case "xhr": { + let req = new XMLHttpRequest(); + req.onload = done; + req.open("GET", url); + req.send(); + break; + } + case "fetch": { + window.fetch(url).then(done); + break; + } + case "worker fetch": { + const worker = new Worker("test_worker.js"); + worker.onmessage = evt => { + if (evt.data.requestDone) { + done(); + } + }; + worker.postMessage({ url }); + break; + } + default: { + browser.test.fail(`Received an unexpected message: ${msg}`); + done(); + } + } + }); + + browser.test.sendMessage("testRequestScript:ready", window.location.href); + } + + function testWorker() { + this.onmessage = evt => { + fetch(evt.data.url).then(() => { + postMessage({ requestDone: true }); + }); + }; + } + + async function createExtension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*", "http://itisatracker.org/*"], + }, + files: { + "test_worker.js": testWorker, + "test_request.js": testRequestScript, + "page_subframe.html": createPage({ script: "test_request.js" }), + "page_with_subframe.html": createPage({ + body: '<iframe src="page_subframe.html"></iframe>', + }), + "page.html": createPage({ script: "test_request.js" }), + }, + }); + + await extension.startup(); + + const EXT_BASE_URL = `moz-extension://${extension.uuid}`; + + return { extension, EXT_BASE_URL }; + } + + const testUrl = "http://example.com/test-cookies"; + const testRequests = ["xhr", "fetch", "worker fetch"]; + const tests = [ + { behavior: "BEHAVIOR_ACCEPT", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT_FOREIGN", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT", cookiesCount: 0 }, + { behavior: "BEHAVIOR_LIMIT_FOREIGN", cookiesCount: 1 }, + { behavior: "BEHAVIOR_REJECT_TRACKER", cookiesCount: 1 }, + ]; + + function clearAllCookies() { + Services.cookies.removeAll(); + let cookies = Services.cookies.cookies; + equal(cookies.length, 0, "There shouldn't be any cookies after clearing"); + } + + async function runTestRequests(extension, cookiesCount, msg) { + for (const testRequest of testRequests) { + clearAllCookies(); + extension.sendMessage(testRequest, testUrl); + await extension.awaitMessage(`${testRequest}:done`); + assertCookiesForHost( + testUrl, + cookiesCount, + `${msg}: cookies count on ${testRequest} "${testUrl}"` + ); + } + } + + for (const { behavior, cookiesCount } of tests) { + info(`Test cookies on http requests with ${behavior}`); + ok( + behavior in Ci.nsICookieService, + `${behavior} is a valid CookieBehavior` + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + Ci.nsICookieService[behavior] + ); + + // Create a new extension to ensure that the cookieBehavior just set is going to be + // used for the requests triggered by the extension page. + const { extension, EXT_BASE_URL } = await createExtension(); + + // Run all the test requests on a top level extension page. + let extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/page.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("testRequestScript:ready"); + await runTestRequests( + extension, + cookiesCount, + `Test top level extension page on ${behavior}` + ); + await extPage.close(); + + // Rerun all the test requests on a sub frame extension page. + extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/page_with_subframe.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("testRequestScript:ready"); + await runTestRequests( + extension, + cookiesCount, + `Test sub frame extension page on ${behavior}` + ); + await extPage.close(); + + await extension.unload(); + } + + // Test tracking url blocking from a webpage subframe. + info( + "Testing blocked tracker cookies in webpage subframe on BEHAVIOR_REJECT_TRACKERS" + ); + Services.prefs.setIntPref( + "network.cookie.cookieBehavior", + BEHAVIOR_REJECT_TRACKER + ); + + const trackerURL = "http://itisatracker.org/test-cookies"; + const { extension, EXT_BASE_URL } = await createExtension(); + const extPage = await ExtensionTestUtils.loadContentPage( + `${EXT_BASE_URL}/_generated_background_page.html`, + { + extension, + remote: extension.extension.remote, + } + ); + clearAllCookies(); + + await extPage.spawn( + "http://example.com/page-with-tracker.html", + async iframeURL => { + const iframe = this.content.document.createElement("iframe"); + iframe.setAttribute("src", iframeURL); + return new Promise(resolve => { + iframe.onload = () => resolve(); + this.content.document.body.appendChild(iframe); + }); + } + ); + + assertCookiesForHost( + trackerURL, + 0, + "Test cookies on web subframe inside top level extension page on BEHAVIOR_REJECT_TRACKER" + ); + clearAllCookies(); + + await extPage.close(); + await extension.unload(); +}); + +// Test that a webpage embedded as a subframe of an extension page is not allowed to use +// IndexedDB and register a ServiceWorker when it shouldn't be based on the cookieBehavior. +add_task( + async function test_webpage_subframe_storage_respect_cookiesBehavior() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + web_accessible_resources: ["subframe.html"], + }, + files: { + "toplevel.html": createPage({ + body: ` + <iframe id="ext" src="subframe.html"></iframe> + <iframe id="web" src="http://example.com/subframe.html"></iframe> + `, + }), + "subframe.html": createPage(), + }, + }); + + Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT); + + await extension.startup(); + + let extensionPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/toplevel.html`, + { + extension, + remote: extension.extension.remote, + } + ); + + let results = await extensionPage.spawn(null, async () => { + let extFrame = this.content.document.querySelector("iframe#ext"); + let webFrame = this.content.document.querySelector("iframe#web"); + + function testIDB(win) { + try { + void win.indexedDB; + return { success: true }; + } catch (err) { + return { error: `${err}` }; + } + } + + async function testServiceWorker(win) { + try { + await win.navigator.serviceWorker.register("sw.js"); + return { success: true }; + } catch (err) { + return { error: `${err}` }; + } + } + + return { + extTopLevel: testIDB(this.content), + extSubFrame: testIDB(extFrame.contentWindow), + webSubFrame: testIDB(webFrame.contentWindow), + webServiceWorker: await testServiceWorker(webFrame.contentWindow), + }; + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/subframe.html" + ); + + results.extSubFrameContent = await contentPage.spawn( + extension.uuid, + uuid => { + return new Promise(resolve => { + let frame = this.content.document.createElement("iframe"); + frame.setAttribute("src", `moz-extension://${uuid}/subframe.html`); + frame.onload = () => { + try { + void frame.contentWindow.indexedDB; + resolve({ success: true }); + } catch (err) { + resolve({ error: `${err}` }); + } + }; + this.content.document.body.appendChild(frame); + }); + } + ); + + Assert.deepEqual( + results.extTopLevel, + { success: true }, + "IndexedDB allowed in a top level extension page" + ); + + Assert.deepEqual( + results.extSubFrame, + { success: true }, + "IndexedDB allowed in a subframe extension page with a top level extension page" + ); + + Assert.deepEqual( + results.webSubFrame, + { error: "SecurityError: The operation is insecure." }, + "IndexedDB not allowed in a subframe webpage with a top level extension page" + ); + Assert.deepEqual( + results.webServiceWorker, + { error: "SecurityError: The operation is insecure." }, + "IndexedDB and Cache not allowed in a service worker registered in the subframe webpage extension page" + ); + + Assert.deepEqual( + results.extSubFrameContent, + { success: true }, + "IndexedDB allowed in a subframe extension page with a top level webpage" + ); + + await extensionPage.close(); + await contentPage.close(); + + await extension.unload(); + } +); + +// Test that the webpage's indexedDB and localStorage are still not allowed from a content script +// when the cookie behavior doesn't allow it, even when they are allowed in the extension pages. +add_task(async function test_content_script_on_cookieBehaviorReject() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", BEHAVIOR_REJECT); + + function contentScript() { + // Ensure that when the current cookieBehavior doesn't allow a webpage to use indexedDB + // or localStorage, then a WebExtension content script is not allowed to use it as well. + browser.test.assertThrows( + () => indexedDB, + /The operation is insecure/, + "a content script can't use indexedDB from a page where it is disallowed" + ); + + browser.test.assertThrows( + () => localStorage, + /The operation is insecure/, + "a content script can't use localStorage from a page where it is disallowed" + ); + + browser.test.notifyPass("cs_disallowed_storage"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("cs_disallowed_storage"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(function clear_cookieBehavior_pref() { + Services.prefs.clearUserPref("network.cookie.cookieBehavior"); +}); + +// Test that localStorage is not in session-only mode for the extension pages, +// even when the session-only mode has been globally enabled, but that the +// lifetime policy currently set is respected in webpage subframes embedded in +// an extension page. +add_task(async function test_localStorage_on_session_lifetimePolicy() { + // localStorage in session-only mode. + Services.prefs.setIntPref("network.cookie.lifetimePolicy", ACCEPT_SESSION); + + function extPageScript() { + localStorage.setItem("test-key", "test-value"); + + browser.test.sendMessage("bg_localStorage_set"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*", "http://itisatracker.org/*"], + }, + files: { + "ext.js": extPageScript, + "ext.html": createPage({ + body: `<iframe src="http://example.com"></iframe>`, + script: "ext.js", + }), + }, + }); + + await extension.startup(); + + let extensionPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/ext.html`, + { + extension, + remote: extension.extension.remote, + } + ); + await extension.awaitMessage("bg_localStorage_set"); + + const results = await extensionPage.spawn(null, async () => { + const iframe = this.content.document.querySelector("iframe").contentWindow; + const { localStorage } = this.content; + + await this.content.fetch("http://itisatracker.org/test-cookies"); + await iframe.fetch("http://example.com/test-cookies"); + + return { + topLevel: { + isSessionOnly: localStorage.isSessionOnly, + domStorageLength: localStorage.length, + domStorageStoredValue: localStorage.getItem("test-key"), + }, + webFrame: { + isSessionOnly: iframe.localStorage.isSessionOnly, + }, + }; + }); + + equal( + results.topLevel.isSessionOnly, + false, + "the extension localStorage is not set in session-only mode" + ); + equal( + results.topLevel.domStorageLength, + 1, + "the extension storage contains the expected number of keys" + ); + equal( + results.topLevel.domStorageStoredValue, + "test-value", + "the extension storage contains the expected data" + ); + + equal( + results.webFrame.isSessionOnly, + true, + "the webpage sub frame localStorage is in session-only mode" + ); + + let cookies = assertCookiesForHost( + "http://example.com", + 1, + "Got a cookie from the extension page request" + ); + ok( + cookies[0].isSession, + "Got a session cookie from the extension page request" + ); + + cookies = assertCookiesForHost( + "http://itisatracker.org", + 1, + "Got a cookie from the web page request" + ); + ok(cookies[0].isSession, "Got a session cookie from the web page request"); + + await extensionPage.close(); + + await extension.unload(); +}); + +add_task(function clear_lifetimePolicy_pref() { + Services.prefs.clearUserPref("network.cookie.lifetimePolicy"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js new file mode 100644 index 0000000000..700794b46c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_firstParty.js @@ -0,0 +1,334 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.org", "example.net", "example.com"], +}); + +function promiseSetCookies() { + return new Promise(resolve => { + server.registerPathHandler("/setCookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Set-Cookie", "none=a; sameSite=none", true); + response.setHeader("Set-Cookie", "lax=b; sameSite=lax", true); + response.setHeader("Set-Cookie", "strict=c; sameSite=strict", true); + response.write("<html></html>"); + resolve(); + }); + }); +} + +function promiseLoadedCookies() { + return new Promise(resolve => { + let cookies; + + server.registerPathHandler("/checkCookies", (request, response) => { + cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + + response.setStatusLine(request.httpVersion, 302, "Moved Permanently"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Location", "/ready"); + }); + + server.registerPathHandler("/navigate", (request, response) => { + cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><script>location = '/checkCookies';</script></html>" + ); + }); + + server.registerPathHandler("/fetch", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html><script>fetch('/checkCookies');</script></html>"); + }); + + server.registerPathHandler("/nestedfetch", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><iframe src='http://example.net/nestedfetch2'></iframe></html>" + ); + }); + + server.registerPathHandler("/nestedfetch2", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + "<html><iframe src='http://example.org/fetch'></iframe></html>" + ); + }); + + server.registerPathHandler("/ready", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html></html>"); + + resolve(cookies); + }); + }); +} + +add_task(async function setup() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true); + + // We don't want to have 'secure' cookies because our test http server doesn't run in https. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); + + // Let's set 3 cookies before loading the extension. + let cookiesPromise = promiseSetCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/setCookies" + ); + await cookiesPromise; + await contentPage.close(); + Assert.equal(Services.cookies.cookies.length, 3); +}); + +add_task(async function test_cookies_firstParty() { + async function pageScript() { + const ifr = document.createElement("iframe"); + ifr.src = "http://example.org/" + location.search.slice(1); + document.body.appendChild(ifr); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["*://example.org/"], + }, + files: { + "page.html": `<body><script src="page.js"></script></body>`, + "page.js": pageScript, + }, + }); + + await extension.startup(); + + // This page will load example.org in an iframe. + let url = `moz-extension://${extension.uuid}/page.html`; + let cookiesPromise = promiseLoadedCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + url + "?checkCookies", + { extension } + ); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's navigate. + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?navigate", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's run a fetch() + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?fetch", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + // Let's run a fetch() from a nested iframe (extension -> example.net -> + // example.org -> fetch) + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage(url + "?nestedfetch", { + extension, + }); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a"); + await contentPage.close(); + + // Let's run a fetch() from a nested iframe (extension -> example.org -> fetch) + cookiesPromise = promiseLoadedCookies(); + contentPage = await ExtensionTestUtils.loadContentPage( + url + "?nestedfetch2", + { + extension, + } + ); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_cookies_iframes() { + server.registerPathHandler("/echocookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write( + request.hasHeader("Cookie") ? request.getHeader("Cookie") : "" + ); + }); + + server.registerPathHandler("/contentScriptHere", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html></html>"); + }); + + server.registerPathHandler("/pageWithFrames", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + + response.write(` + <html> + <iframe src="http://example.com/contentScriptHere"></iframe> + <iframe src="http://example.net/contentScriptHere"></iframe> + </html> + `); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["*://example.org/"], + content_scripts: [ + { + js: ["contentScript.js"], + matches: [ + "*://example.com/contentScriptHere", + "*://example.net/contentScriptHere", + ], + run_at: "document_end", + all_frames: true, + }, + ], + }, + files: { + "contentScript.js": async () => { + const res = await fetch("http://example.org/echocookies"); + const cookies = await res.text(); + browser.test.assertEq( + "none=a", + cookies, + "expected cookies in content script" + ); + browser.test.sendMessage("extfetch:" + location.hostname); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/pageWithFrames" + ); + await Promise.all([ + extension.awaitMessage("extfetch:example.com"), + extension.awaitMessage("extfetch:example.net"), + ]); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_cookies_background() { + async function background() { + const res = await fetch("http://example.org/echocookies", { + credentials: "include", + }); + const cookies = await res.text(); + browser.test.sendMessage("fetchcookies", cookies); + } + + const tests = [ + { + permissions: ["http://example.org/*"], + cookies: "none=a; lax=b; strict=c", + }, + { + permissions: [], + cookies: "none=a", + }, + ]; + + for (let test of tests) { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: test.permissions, + }, + }); + + server.registerPathHandler("/echocookies", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader( + "Access-Control-Allow-Origin", + `moz-extension://${extension.uuid}`, + false + ); + response.setHeader("Access-Control-Allow-Credentials", "true", false); + response.write( + request.hasHeader("Cookie") ? request.getHeader("Cookie") : "" + ); + }); + + await extension.startup(); + equal( + await extension.awaitMessage("fetchcookies"), + test.cookies, + "extension with permissions can see SameSite-restricted cookies" + ); + + await extension.unload(); + } +}); + +add_task(async function test_cookies_contentScript() { + server.registerPathHandler("/empty", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<html><body></body></html>"); + }); + + async function contentScript() { + let res = await fetch("http://example.org/checkCookies"); + browser.test.assertEq(location.origin + "/ready", res.url, "request OK"); + browser.test.sendMessage("fetch-done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["contentscript.js"], + matches: ["*://*/*"], + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + }); + + await extension.startup(); + + let cookiesPromise = promiseLoadedCookies(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/empty" + ); + await extension.awaitMessage("fetch-done"); + + // Let's check the cookies received during the last loading. + Assert.equal(await cookiesPromise, "none=a; lax=b; strict=c"); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js new file mode 100644 index 0000000000..2847698340 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_cookies_samesite.js @@ -0,0 +1,109 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.org"] }); +server.registerPathHandler("/sameSiteCookiesApiTest", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_samesite_cookies() { + function contentScript() { + document.cookie = "test1=whatever"; + document.cookie = "test2=whatever; SameSite=lax"; + document.cookie = "test3=whatever; SameSite=strict"; + browser.runtime.sendMessage("do-check-cookies"); + } + async function background() { + await new Promise(resolve => { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("do-check-cookies", msg, "expected message"); + resolve(); + }); + }); + + const url = "https://example.org/"; + + // Baseline. Every cookie must have the expected sameSite. + let cookie = await browser.cookies.get({ url, name: "test1" }); + browser.test.assertEq( + "no_restriction", + cookie.sameSite, + "Expected sameSite for test1" + ); + + cookie = await browser.cookies.get({ url, name: "test2" }); + browser.test.assertEq( + "lax", + cookie.sameSite, + "Expected sameSite for test2" + ); + + cookie = await browser.cookies.get({ url, name: "test3" }); + browser.test.assertEq( + "strict", + cookie.sameSite, + "Expected sameSite for test3" + ); + + // Testing cookies.getAll + cookies.set + let cookies = await browser.cookies.getAll({ url, name: "test3" }); + browser.test.assertEq(1, cookies.length, "There is only one test3 cookie"); + + cookie = await browser.cookies.set({ + url, + name: "test3", + value: "newvalue", + }); + browser.test.assertEq( + "no_restriction", + cookie.sameSite, + "sameSite defaults to no_restriction" + ); + + for (let sameSite of ["no_restriction", "lax", "strict"]) { + cookie = await browser.cookies.set({ url, name: "test3", sameSite }); + browser.test.assertEq( + sameSite, + cookie.sameSite, + `Expected sameSite=${sameSite} in return value of cookies.set` + ); + cookies = await browser.cookies.getAll({ url, name: "test3" }); + browser.test.assertEq( + 1, + cookies.length, + `test3 is still the only cookie after setting sameSite=${sameSite}` + ); + browser.test.assertEq( + sameSite, + cookies[0].sameSite, + `test3 was updated to sameSite=${sameSite}` + ); + } + + browser.test.notifyPass("cookies"); + } + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["cookies", "*://example.org/"], + content_scripts: [ + { + matches: ["*://example.org/sameSiteCookiesApiTest*"], + js: ["contentscript.js"], + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/sameSiteCookiesApiTest" + ); + await extension.awaitFinish("cookies"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js new file mode 100644 index 0000000000..a0a552f64f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_debugging_utils.js @@ -0,0 +1,316 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +add_task(async function testExtensionDebuggingUtilsCleanup() { + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + const expectedEmptyDebugUtils = { + hiddenXULWindow: null, + cacheSize: 0, + }; + + let { hiddenXULWindow, debugBrowserPromises } = ExtensionParent.DebugUtils; + + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "No ExtensionDebugUtils resources has been allocated yet" + ); + + await extension.startup(); + + await extension.awaitMessage("background.ready"); + + hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow; + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "No debugging resources has been yet allocated once the extension is running" + ); + + const fakeAddonActor = { + addonId: extension.id, + }; + + const anotherAddonActor = { + addonId: extension.id, + }; + + const waitFirstBrowser = ExtensionParent.DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + const waitSecondBrowser = ExtensionParent.DebugUtils.getExtensionProcessBrowser( + anotherAddonActor + ); + + const addonDebugBrowser = await waitFirstBrowser; + equal( + addonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + + equal( + await waitSecondBrowser, + addonDebugBrowser, + "Two addon debugging actors related to the same addon get the same browser element " + ); + + equal( + debugBrowserPromises.size, + 1, + "The expected resources has been allocated" + ); + + const nonExistentAddonActor = { + addonId: "non-existent-addon@test", + }; + + const waitRejection = ExtensionParent.DebugUtils.getExtensionProcessBrowser( + nonExistentAddonActor + ); + + await Assert.rejects( + waitRejection, + /Extension not found/, + "Reject with the expected message for non existent addons" + ); + + equal( + debugBrowserPromises.size, + 1, + "No additional debugging resources has been allocated" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + debugBrowserPromises.size, + 1, + "The addon debugging browser is cached until all the related actors have released it" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + anotherAddonActor + ); + + hiddenXULWindow = ExtensionParent.DebugUtils.hiddenXULWindow; + + deepEqual( + { hiddenXULWindow, cacheSize: debugBrowserPromises.size }, + expectedEmptyDebugUtils, + "All the allocated debugging resources has been cleared" + ); + + await extension.unload(); +}); + +add_task(async function testExtensionDebuggingUtilsAddonReloaded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-reloaded@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + let fakeAddonActor = { + addonId: extension.id, + }; + + const addonDebugBrowser = await ExtensionParent.DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + equal( + addonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of requested debug browsers" + ); + + const { chromeDocument } = ExtensionParent.DebugUtils.hiddenXULWindow; + + ok( + addonDebugBrowser.parentElement === chromeDocument.documentElement, + "The addon debugging browser is part of the hiddenXULWindow chromeDocument" + ); + + await extension.unload(); + + // Install an extension with the same id to recreate for the DebugUtils + // conditions similar to an addon reloaded while the Addon Debugger is opened. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-reloaded@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of requested debug browsers" + ); + + const newAddonDebugBrowser = await ExtensionParent.DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + addonDebugBrowser, + newAddonDebugBrowser, + "The existent debugging browser has been reused" + ); + + equal( + newAddonDebugBrowser.isRemoteBrowser, + extension.extension.remote, + "The addon debugging browser has the expected remote type" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 0, + "All the addon debugging browsers has been released" + ); + + await extension.unload(); +}); + +add_task(async function testExtensionDebuggingUtilsWithMultipleAddons() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-addon-1@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + let anotherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "test-addon-2@test.mozilla.com", + }, + }, + }, + background() { + browser.test.sendMessage("background.ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("background.ready"); + + await anotherExtension.startup(); + await anotherExtension.awaitMessage("background.ready"); + + const fakeAddonActor = { + addonId: extension.id, + }; + + const anotherFakeAddonActor = { + addonId: anotherExtension.id, + }; + + const { DebugUtils } = ExtensionParent; + const debugBrowser = await DebugUtils.getExtensionProcessBrowser( + fakeAddonActor + ); + const anotherDebugBrowser = await DebugUtils.getExtensionProcessBrowser( + anotherFakeAddonActor + ); + + const chromeDocument = DebugUtils.hiddenXULWindow.chromeDocument; + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 2, + "Got the expected number of debug browsers requested" + ); + ok( + debugBrowser.parentElement === chromeDocument.documentElement, + "The first debug browser is part of the hiddenXUL chromeDocument" + ); + ok( + anotherDebugBrowser.parentElement === chromeDocument.documentElement, + "The second debug browser is part of the hiddenXUL chromeDocument" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + fakeAddonActor + ); + + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 1, + "Got the expected number of debug browsers requested" + ); + + ok( + anotherDebugBrowser.parentElement === chromeDocument.documentElement, + "The second debug browser is still part of the hiddenXUL chromeDocument" + ); + + ok( + debugBrowser.parentElement == null, + "The first debug browser has been removed from the hiddenXUL chromeDocument" + ); + + await ExtensionParent.DebugUtils.releaseExtensionProcessBrowser( + anotherFakeAddonActor + ); + + ok( + anotherDebugBrowser.parentElement == null, + "The second debug browser has been removed from the hiddenXUL chromeDocument" + ); + equal( + ExtensionParent.DebugUtils.debugBrowserPromises.size, + 0, + "All the addon debugging browsers has been released" + ); + + await extension.unload(); + await anotherExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_dns.js b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js new file mode 100644 index 0000000000..d7f9d6efe9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_dns.js @@ -0,0 +1,176 @@ +"use strict"; + +// Some test machines and android are not returning ipv6, turn it +// off to get consistent test results. +Services.prefs.setBoolPref("network.dns.disableIPv6", true); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +function getExtension(background = undefined) { + let manifest = { + permissions: ["dns", "proxy"], + }; + return ExtensionTestUtils.loadExtension({ + manifest, + background() { + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "proxy") { + await browser.proxy.settings.set({ value: data }); + browser.test.sendMessage("proxied"); + return; + } + browser.test.log(`=== dns resolve test ${JSON.stringify(data)}`); + browser.dns + .resolve(data.hostname, data.flags) + .then(result => { + browser.test.log( + `=== dns resolve result ${JSON.stringify(result)}` + ); + browser.test.sendMessage("resolved", result); + }) + .catch(e => { + browser.test.log(`=== dns resolve error ${e.message}`); + browser.test.sendMessage("resolved", { message: e.message }); + }); + }); + browser.test.sendMessage("ready"); + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }); +} + +const tests = [ + { + request: { + hostname: "localhost", + }, + expect: { + addresses: ["127.0.0.1"], // ipv6 disabled , "::1" + }, + }, + { + request: { + hostname: "localhost", + flags: ["offline"], + }, + expect: { + addresses: ["127.0.0.1"], // ipv6 disabled , "::1" + }, + }, + { + request: { + hostname: "test.example", + }, + expect: { + // android will error with offline + error: /NS_ERROR_UNKNOWN_HOST|NS_ERROR_OFFLINE/, + }, + }, + { + request: { + hostname: "127.0.0.1", + flags: ["canonical_name"], + }, + expect: { + canonicalName: "127.0.0.1", + addresses: ["127.0.0.1"], + }, + }, + { + request: { + hostname: "localhost", + flags: ["disable_ipv6"], + }, + expect: { + addresses: ["127.0.0.1"], + }, + }, +]; + +add_task(async function startup() { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_dns_resolve() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let test of tests) { + extension.sendMessage("resolve", test.request); + let result = await extension.awaitMessage("resolved"); + if (test.expect.error) { + ok( + test.expect.error.test(result.message), + `expected error ${result.message}` + ); + } else { + equal( + result.canonicalName, + test.expect.canonicalName, + "canonicalName match" + ); + // It seems there are platform differences happening that make this + // testing difficult. We're going to rely on other existing dns tests to validate + // the dns service itself works and only validate that we're getting generally + // expected results in the webext api. + ok( + result.addresses.length >= test.expect.addresses.length, + "expected number of addresses returned" + ); + if (test.expect.addresses.length && result.addresses.length) { + ok( + result.addresses.includes(test.expect.addresses[0]), + "got expected ip address" + ); + } + } + } + + await extension.unload(); +}); + +add_task(async function test_dns_resolve_socks() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("proxy", { + proxyType: "manual", + socks: "127.0.0.1", + socksVersion: 5, + proxyDNS: true, + }); + await extension.awaitMessage("proxied"); + equal( + Services.prefs.getIntPref("network.proxy.type"), + 1 /* PROXYCONFIG_MANUAL */, + "manual proxy" + ); + equal( + Services.prefs.getStringPref("network.proxy.socks"), + "127.0.0.1", + "socks proxy" + ); + ok( + Services.prefs.getBoolPref("network.proxy.socks_remote_dns"), + "socks remote dns" + ); + extension.sendMessage("resolve", { + hostname: "mozilla.org", + }); + let result = await extension.awaitMessage("resolved"); + ok( + /NS_ERROR_UNKNOWN_PROXY_HOST/.test(result.message), + `expected error ${result.message}` + ); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js new file mode 100644 index 0000000000..f65df707e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads.js @@ -0,0 +1,38 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_downloads_api_namespace_and_permissions() { + function backgroundScript() { + browser.test.assertTrue(!!browser.downloads, "`downloads` API is present."); + browser.test.assertTrue( + !!browser.downloads.FilenameConflictAction, + "`downloads.FilenameConflictAction` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.InterruptReason, + "`downloads.InterruptReason` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.DangerType, + "`downloads.DangerType` enum is present." + ); + browser.test.assertTrue( + !!browser.downloads.State, + "`downloads.State` enum is present." + ); + browser.test.notifyPass("downloads tests"); + } + + let extensionData = { + background: backgroundScript, + manifest: { + permissions: ["downloads", "downloads.open"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("downloads tests"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js new file mode 100644 index 0000000000..aa91cd7c88 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_cookies.js @@ -0,0 +1,216 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { UrlClassifierTestUtils } = ChromeUtils.import( + "resource://testing-common/UrlClassifierTestUtils.jsm" +); + +// Value for network.cookie.cookieBehavior to reject all third-party cookies. +const { BEHAVIOR_REJECT_FOREIGN } = Ci.nsICookieService; + +const server = createHttpServer({ hosts: ["example.net", "itisatracker.org"] }); +server.registerPathHandler("/setcookies", (request, response) => { + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + response.setHeader("Set-Cookie", "c_none=1; sameSite=none", true); + response.setHeader("Set-Cookie", "c_lax=1; sameSite=lax", true); + response.setHeader("Set-Cookie", "c_strict=1; sameSite=strict", true); +}); + +server.registerPathHandler("/download", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + + let cookies = request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; + // Assign the result through the MIME-type, to make it easier to read the + // result via the downloads API. + response.setHeader("Content-Type", `dummy/${encodeURIComponent(cookies)}`); + // Response of length 7. + response.write("1234567"); +}); + +server.registerPathHandler("/redirect", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Found"); + response.setHeader("Location", "/download"); +}); + +function createDownloadTestExtension(extraPermissions = []) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads", ...extraPermissions], + }, + incognitoOverride: "spanning", + background() { + async function getCookiesForDownload(url) { + let donePromise = new Promise(resolve => { + browser.downloads.onChanged.addListener(async delta => { + if (delta.state?.current === "complete") { + resolve(delta.id); + } + }); + }); + // TODO bug 1653636: Remove this when the correct browsing mode is used. + const incognito = browser.extension.inIncognitoContext; + let downloadId = await browser.downloads.download({ url, incognito }); + browser.test.assertEq(await donePromise, downloadId, "got download"); + let [download] = await browser.downloads.search({ id: downloadId }); + browser.test.log(`Download results: ${JSON.stringify(download)}`); + + // Delete the file since we aren't interested in it. + // TODO bug 1654819: On Windows the file may be recreated. + await browser.downloads.removeFile(download.id); + // Sanity check to verify that we got the result from /download. + browser.test.assertEq(7, download.fileSize, "download succeeded"); + + // The "/download" endpoint mirrors received cookies via Content-Type. + let cookies = decodeURIComponent(download.mime.replace("dummy/", "")); + return cookies; + } + + browser.test.onMessage.addListener(async url => { + browser.test.sendMessage("result", await getCookiesForDownload(url)); + }); + }, + }); +} + +async function downloadAndGetCookies(extension, url) { + extension.sendMessage(url); + return extension.awaitMessage("result"); +} + +add_task(async function setup() { + const nsIFile = Ci.nsIFile; + const downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + // Support sameSite=none despite the server using http instead of https. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); + async function loadAndClose(url) { + let contentPage = await ExtensionTestUtils.loadContentPage(url); + await contentPage.close(); + } + // Generate cookies for use in this test. + await loadAndClose("http://example.net/setcookies"); + await loadAndClose("http://itisatracker.org/setcookies"); + + await UrlClassifierTestUtils.addTestTrackers(); + registerCleanupFunction(() => { + UrlClassifierTestUtils.cleanupTestTrackers(); + Services.cookies.removeAll(); + + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + downloadDir.remove(false); + }); +}); + +// Checks that (sameSite) cookies are included in download requests. +add_task(async function download_cookies_basic() { + let extension = createDownloadTestExtension(["*://example.net/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with sameSite cookies" + ); + + equal( + await downloadAndGetCookies(extension, "http://example.net/redirect"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with redirect" + ); + + await runWithPrefs( + [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]], + async () => { + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with all third-party cookies disabled" + ); + } + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies are included even when tracking protection +// would block cookies from third-party requests. +add_task(async function download_cookies_from_tracker_url() { + let extension = createDownloadTestExtension(["*://itisatracker.org/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://itisatracker.org/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download of itisatracker.org" + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies are included even without host permissions. +add_task(async function download_cookies_without_host_permissions() { + let extension = createDownloadTestExtension(); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download without host permissions" + ); + + equal( + await downloadAndGetCookies(extension, "http://itisatracker.org/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download of itisatracker.org" + ); + + await runWithPrefs( + [["network.cookie.cookieBehavior", BEHAVIOR_REJECT_FOREIGN]], + async () => { + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download with all third-party cookies disabled" + ); + } + ); + + await extension.unload(); +}); + +// Checks that (sameSite) cookies from private browsing are included. +add_task(async function download_cookies_in_perma_private_browsing() { + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + let extension = createDownloadTestExtension(["*://example.net/*"]); + await extension.startup(); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "", + "Initially no cookies in permanent private browsing mode" + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.net/setcookies", + { privateBrowsing: true } + ); + + equal( + await downloadAndGetCookies(extension, "http://example.net/download"), + "c_none=1; c_lax=1; c_strict=1", + "Cookies for downloads.download in perma-private-browsing mode" + ); + + await extension.unload(); + await contentPage.close(); + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js new file mode 100644 index 0000000000..a9edb9d13e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_download.js @@ -0,0 +1,680 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { Downloads } = ChromeUtils.import( + "resource://gre/modules/Downloads.jsm" +); + +const gServer = createHttpServer(); +gServer.registerDirectory("/data/", do_get_file("data")); + +gServer.registerPathHandler("/dir/", (_, res) => res.write("length=8")); + +const WINDOWS = AppConstants.platform == "win"; + +const BASE = `http://localhost:${gServer.identity.primaryPort}/`; +const FILE_NAME = "file_download.txt"; +const FILE_NAME_W_SPACES = "file download.txt"; +const FILE_URL = BASE + "data/" + FILE_NAME; +const FILE_NAME_UNIQUE = "file_download(1).txt"; +const FILE_LEN = 46; + +let downloadDir; + +function setup() { + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +} + +function backgroundScript() { + let blobUrl; + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + let options = args[0]; + + if (options.blobme) { + let blob = new Blob(options.blobme); + delete options.blobme; + blobUrl = options.url = window.URL.createObjectURL(blob); + } + + try { + let id = await browser.downloads.download(options); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "killTheBlob") { + window.URL.revokeObjectURL(blobUrl); + blobUrl = null; + } + }); + + browser.test.sendMessage("ready"); +} + +// This function is a bit of a sledgehammer, it looks at every download +// the browser knows about and waits for all active downloads to complete. +// But we only start one at a time and only do a handful in total, so +// this lets us test download() without depending on anything else. +async function waitForDownloads() { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + let inprogress = downloads.filter(dl => !dl.stopped); + return Promise.all(inprogress.map(dl => dl.whenSucceeded())); +} + +// Create a file in the downloads directory. +function touch(filename) { + let file = downloadDir.clone(); + file.append(filename); + file.create(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE); +} + +// Remove a file in the downloads directory. +function remove(filename, recursive = false) { + let file = downloadDir.clone(); + file.append(filename); + file.remove(recursive); +} + +add_task(async function test_downloads() { + setup(); + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})()`, + manifest: { + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + function download(options) { + extension.sendMessage("download.request", options); + return extension.awaitMessage("download.done"); + } + + async function testDownload(options, localFile, expectedSize, description) { + let msg = await download(options); + equal( + msg.status, + "success", + `downloads.download() works with ${description}` + ); + + await waitForDownloads(); + + let localPath = downloadDir.clone(); + let parts = Array.isArray(localFile) ? localFile : [localFile]; + + parts.map(p => localPath.append(p)); + equal( + localPath.fileSize, + expectedSize, + "Downloaded file has expected size" + ); + localPath.remove(false); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + info("extension started"); + + // Call download() with just the url property. + await testDownload({ url: FILE_URL }, FILE_NAME, FILE_LEN, "just source"); + + // Call download() with a filename property. + await testDownload( + { + url: FILE_URL, + filename: "newpath.txt", + }, + "newpath.txt", + FILE_LEN, + "source and filename" + ); + + // Call download() with a filename with subdirs. + await testDownload( + { + url: FILE_URL, + filename: "sub/dir/file", + }, + ["sub", "dir", "file"], + FILE_LEN, + "source and filename with subdirs" + ); + + // Call download() with a filename with existing subdirs. + await testDownload( + { + url: FILE_URL, + filename: "sub/dir/file2", + }, + ["sub", "dir", "file2"], + FILE_LEN, + "source and filename with existing subdirs" + ); + + // Only run Windows path separator test on Windows. + if (WINDOWS) { + // Call download() with a filename with Windows path separator. + await testDownload( + { + url: FILE_URL, + filename: "sub\\dir\\file3", + }, + ["sub", "dir", "file3"], + FILE_LEN, + "filename with Windows path separator" + ); + } + remove("sub", true); + + // Call download(), filename with subdir, skipping parts. + await testDownload( + { + url: FILE_URL, + filename: "skip//part", + }, + ["skip", "part"], + FILE_LEN, + "source, filename, with subdir, skipping parts" + ); + remove("skip", true); + + // Check conflictAction of "uniquify". + touch(FILE_NAME); + await testDownload( + { + url: FILE_URL, + conflictAction: "uniquify", + }, + FILE_NAME_UNIQUE, + FILE_LEN, + "conflictAction=uniquify" + ); + // todo check that preexisting file was not modified? + remove(FILE_NAME); + + // Check conflictAction of "overwrite". + touch(FILE_NAME); + await testDownload( + { + url: FILE_URL, + conflictAction: "overwrite", + }, + FILE_NAME, + FILE_LEN, + "conflictAction=overwrite" + ); + + // Try to download in invalid url + await download({ url: "this is not a valid URL" }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with invalid url"); + ok( + /not a valid URL/.test(msg.errmsg), + "error message for invalid url is correct" + ); + }); + + // Try to download to an empty path. + await download({ + url: FILE_URL, + filename: "", + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with empty filename" + ); + equal( + msg.errmsg, + "filename must not be empty", + "error message for empty filename is correct" + ); + }); + + // Try to download to an absolute path. + const absolutePath = OS.Path.join( + WINDOWS ? "\\tmp" : "/tmp", + "file_download.txt" + ); + await download({ + url: FILE_URL, + filename: absolutePath, + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with absolute filename" + ); + equal( + msg.errmsg, + "filename must not be an absolute path", + `error message for absolute path (${absolutePath}) is correct` + ); + }); + + if (WINDOWS) { + await download({ + url: FILE_URL, + filename: "C:\\file_download.txt", + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with absolute filename" + ); + equal( + msg.errmsg, + "filename must not be an absolute path", + "error message for absolute path with drive letter is correct" + ); + }); + } + + // Try to download to a relative path containing .. + await download({ + url: FILE_URL, + filename: OS.Path.join("..", "file_download.txt"), + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with back-references" + ); + equal( + msg.errmsg, + "filename must not contain back-references (..)", + "error message for back-references is correct" + ); + }); + + // Try to download to a long relative path containing .. + await download({ + url: FILE_URL, + filename: OS.Path.join("foo", "..", "..", "file_download.txt"), + }).then(msg => { + equal( + msg.status, + "error", + "downloads.download() fails with back-references" + ); + equal( + msg.errmsg, + "filename must not contain back-references (..)", + "error message for back-references is correct" + ); + }); + + // Test illegal characters. + await download({ + url: FILE_URL, + filename: "like:this", + }).then(msg => { + equal(msg.status, "error", "downloads.download() fails with illegal chars"); + equal( + msg.errmsg, + "filename must not contain illegal characters", + "error message correct" + ); + }); + + // Try to download a blob url + const BLOB_STRING = "Hello, world"; + await testDownload( + { + blobme: [BLOB_STRING], + filename: FILE_NAME, + }, + FILE_NAME, + BLOB_STRING.length, + "blob url" + ); + extension.sendMessage("killTheBlob"); + + // Try to download a blob url without a given filename + await testDownload( + { + blobme: [BLOB_STRING], + }, + "download", + BLOB_STRING.length, + "blob url with no filename" + ); + extension.sendMessage("killTheBlob"); + + // Download a normal URL with an empty filename part. + await testDownload( + { + url: BASE + "dir/", + }, + "download", + 8, + "normal url with empty filename" + ); + + // Download a filename with multiple spaces, url is ignored for this test. + await testDownload( + { + url: FILE_URL, + filename: "a file.txt", + }, + "a file.txt", + FILE_LEN, + "filename with multiple spaces" + ); + + // Download a normal URL with a leafname containing multiple spaces. + // Note: spaces are compressed by file name normalization. + await testDownload( + { + url: BASE + "data/" + FILE_NAME_W_SPACES, + }, + FILE_NAME_W_SPACES.replace(/\s+/, " "), + FILE_LEN, + "leafname with multiple spaces" + ); + + // Check that the "incognito" property is supported. + await testDownload( + { + url: FILE_URL, + incognito: false, + }, + FILE_NAME, + FILE_LEN, + "incognito=false" + ); + + await testDownload( + { + url: FILE_URL, + incognito: true, + }, + FILE_NAME, + FILE_LEN, + "incognito=true" + ); + + await extension.unload(); +}); + +async function testHttpErrors(allowHttpErrors) { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/error`; + const content = "HTTP Error test"; + + server.registerPathHandler("/error", (request, response) => { + response.setStatusLine( + "1.1", + parseInt(request.queryString, 10), + "Some Error" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Content-Length", content.length.toString()); + response.write(content); + }); + + function background(code) { + let dlid = 0; + let expectedState; + browser.test.onMessage.addListener(async options => { + try { + expectedState = options.allowHttpErrors ? "complete" : "interrupted"; + dlid = await browser.downloads.download(options); + } catch (err) { + browser.test.fail(`Unexpected error in downloads.download(): ${err}`); + } + }); + function onChanged({ id, state }) { + if (dlid !== id || !state || state.current === "in_progress") { + return; + } + browser.test.assertEq(state.current, expectedState, "correct state"); + browser.downloads.search({ id }).then(([download]) => { + browser.test.sendMessage("done", download.error); + }); + } + browser.downloads.onChanged.addListener(onChanged); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background, + }); + await extension.startup(); + + async function download(code, expected_when_disallowed) { + const options = { + url: url + "?" + code, + filename: `test-${code}`, + conflictAction: "overwrite", + allowHttpErrors, + }; + extension.sendMessage(options); + const rv = await extension.awaitMessage("done"); + + if (allowHttpErrors) { + const localPath = downloadDir.clone(); + localPath.append(options.filename); + equal( + localPath.fileSize, + // The 20x No content errors will not produce any response body, + // only "true" errors do. + code >= 400 ? content.length : 0, + "Downloaded file has expected size" + code + ); + localPath.remove(false); + + ok(!rv, "error must be ignored and hence false-y"); + return; + } + + equal( + rv, + expected_when_disallowed, + "error must have the correct InterruptReason" + ); + } + + await download(204, "SERVER_BAD_CONTENT"); // No Content + await download(205, "SERVER_BAD_CONTENT"); // Reset Content + await download(404, "SERVER_BAD_CONTENT"); // Not Found + await download(403, "SERVER_FORBIDDEN"); // Forbidden + await download(402, "SERVER_UNAUTHORIZED"); // Unauthorized + await download(407, "SERVER_UNAUTHORIZED"); // Proxy auth required + await download(504, "SERVER_FAILED"); //General errors, here Gateway Timeout + + await extension.unload(); +} + +add_task(function test_download_disallowed_http_errors() { + return testHttpErrors(false); +}); + +add_task(function test_download_allowed_http_errors() { + return testHttpErrors(true); +}); + +add_task(async function test_download_http_details() { + const server = createHttpServer(); + const url = `http://localhost:${server.identity.primaryPort}/post-log`; + + let received; + server.registerPathHandler("/post-log", (request, response) => { + received = request; + response.setHeader("Set-Cookie", "monster=", false); + }); + + // Confirm received vs. expected values. + function confirm(method, headers = {}, body) { + equal(received.method, method, "method is correct"); + + for (let name in headers) { + ok(received.hasHeader(name), `header ${name} received`); + equal( + received.getHeader(name), + headers[name], + `header ${name} is correct` + ); + } + + if (body) { + const str = NetUtil.readInputStreamToString( + received.bodyInputStream, + received.bodyInputStream.available() + ); + equal(str, body, "body is correct"); + } + } + + function background() { + browser.test.onMessage.addListener(async options => { + try { + await browser.downloads.download(options); + } catch (err) { + browser.test.sendMessage("done", { err: err.message }); + } + }); + browser.downloads.onChanged.addListener(({ state }) => { + if (state && state.current === "complete") { + browser.test.sendMessage("done", { ok: true }); + } + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background, + incognitoOverride: "spanning", + }); + await extension.startup(); + + function download(options) { + options.url = url; + options.conflictAction = "overwrite"; + + extension.sendMessage(options); + return extension.awaitMessage("done"); + } + + // Test that site cookies are sent with download requests, + // and "incognito" downloads use a separate cookie jar. + let testDownloadCookie = async function(incognito) { + let result = await download({ incognito }); + ok(result.ok, `preflight to set cookies with incognito=${incognito}`); + ok(!received.hasHeader("cookie"), "first request has no cookies"); + + result = await download({ incognito }); + ok(result.ok, `download with cookie with incognito=${incognito}`); + equal( + received.getHeader("cookie"), + "monster=", + "correct cookie header sent for second download" + ); + }; + + await testDownloadCookie(false); + await testDownloadCookie(true); + + // Test method option. + let result = await download({}); + ok(result.ok, "download works without the method option, defaults to GET"); + confirm("GET"); + + result = await download({ method: "PUT" }); + ok(!result.ok, "download rejected with PUT method"); + ok( + /method: Invalid enumeration/.test(result.err), + "descriptive error message" + ); + + result = await download({ method: "POST" }); + ok(result.ok, "download works with POST method"); + confirm("POST"); + + // Test body option values. + result = await download({ body: [] }); + ok(!result.ok, "download rejected because of non-string body"); + ok(/body: Expected string/.test(result.err), "descriptive error message"); + + result = await download({ method: "POST", body: "of work" }); + ok(result.ok, "download works with POST method and body"); + confirm("POST", { "Content-Length": 7 }, "of work"); + + // Test custom headers. + result = await download({ headers: [{ name: "X-Custom" }] }); + ok(!result.ok, "download rejected because of missing header value"); + ok(/"value" is required/.test(result.err), "descriptive error message"); + + result = await download({ headers: [{ name: "X-Custom", value: "13" }] }); + ok(result.ok, "download works with a custom header"); + confirm("GET", { "X-Custom": "13" }); + + // Test Referer header. + const referer = "http://example.org/test"; + result = await download({ headers: [{ name: "Referer", value: referer }] }); + ok(result.ok, "download works with Referer header"); + confirm("GET", { Referer: referer }); + + // Test forbidden headers. + result = await download({ headers: [{ name: "DNT", value: "1" }] }); + ok(!result.ok, "download rejected because of forbidden header name DNT"); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({ + headers: [{ name: "Proxy-Connection", value: "keep" }], + }); + ok( + !result.ok, + "download rejected because of forbidden header name prefix Proxy-" + ); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + result = await download({ headers: [{ name: "Sec-ret", value: "13" }] }); + ok( + !result.ok, + "download rejected because of forbidden header name prefix Sec-" + ); + ok(/Forbidden request header/.test(result.err), "descriptive error message"); + + remove("post-log"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js new file mode 100644 index 0000000000..9de40a8c9c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_misc.js @@ -0,0 +1,1069 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.import( + "resource://gre/modules/Downloads.jsm" +); + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const ROOT = `http://localhost:${server.identity.primaryPort}`; +const BASE = `${ROOT}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; + +// Keep these in sync with code in interruptible.sjs +const INT_PARTIAL_LEN = 15; +const INT_TOTAL_LEN = 31; + +const TEST_DATA = "This is 31 bytes of sample data"; +const TOTAL_LEN = TEST_DATA.length; +const PARTIAL_LEN = 15; + +// A handler to let us systematically test pausing/resuming/canceling +// of downloads. This target represents a small text file but a simple +// GET will stall after sending part of the data, to give the test code +// a chance to pause or do other operations on an in-progress download. +// A resumed download (ie, a GET with a Range: header) will allow the +// download to complete. +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain", false); + + if (request.hasHeader("Range")) { + let start, end; + let matches = request + .getHeader("Range") + .match(/^\s*bytes=(\d+)?-(\d+)?\s*$/); + if (matches != null) { + start = matches[1] ? parseInt(matches[1], 10) : 0; + end = matches[2] ? parseInt(matches[2], 10) : TOTAL_LEN - 1; + } + + if (end == undefined || end >= TOTAL_LEN) { + response.setStatusLine( + request.httpVersion, + 416, + "Requested Range Not Satisfiable" + ); + response.setHeader("Content-Range", `*/${TOTAL_LEN}`, false); + response.finish(); + return; + } + + response.setStatusLine(request.httpVersion, 206, "Partial Content"); + response.setHeader("Content-Range", `${start}-${end}/${TOTAL_LEN}`, false); + response.write(TEST_DATA.slice(start, end + 1)); + } else if (request.queryString.includes("stream")) { + response.processAsync(); + response.setHeader("Content-Length", "10000", false); + response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + setInterval(() => { + response.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); + }, 50); + } else { + response.processAsync(); + response.setHeader("Content-Length", `${TOTAL_LEN}`, false); + response.write(TEST_DATA.slice(0, PARTIAL_LEN)); + } + + registerCleanupFunction(() => { + try { + response.finish(); + } catch (e) { + // This will throw, but we don't care at this point. + } + }); +} + +server.registerPrefixHandler("/interruptible/", handleRequest); + +let interruptibleCount = 0; +function getInterruptibleUrl(filename = "interruptible.html") { + let n = interruptibleCount++; + return `${ROOT}/interruptible/${filename}?count=${n}`; +} + +function backgroundScript() { + let events = new Set(); + let eventWaiter = null; + + browser.downloads.onCreated.addListener(data => { + events.add({ type: "onCreated", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + browser.downloads.onChanged.addListener(data => { + events.add({ type: "onChanged", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + browser.downloads.onErased.addListener(data => { + events.add({ type: "onErased", data }); + if (eventWaiter) { + eventWaiter(); + } + }); + + // Returns a promise that will resolve when the given list of expected + // events have all been seen. By default, succeeds only if the exact list + // of expected events is seen in the given order. options.exact can be + // set to false to allow other events and options.inorder can be set to + // false to allow the events to arrive in any order. + function waitForEvents(expected, options = {}) { + function compare(a, b) { + if (typeof b == "object" && b != null) { + if (typeof a != "object") { + return false; + } + return Object.keys(b).every(fld => compare(a[fld], b[fld])); + } + return a == b; + } + + const exact = "exact" in options ? options.exact : true; + const inorder = "inorder" in options ? options.inorder : true; + return new Promise((resolve, reject) => { + function check() { + function fail(msg) { + browser.test.fail(msg); + reject(new Error(msg)); + } + if (events.size < expected.length) { + return; + } + if (exact && expected.length < events.size) { + fail( + `Got ${events.size} events but only expected ${expected.length}` + ); + return; + } + + let remaining = new Set(events); + if (inorder) { + for (let event of events) { + if (compare(event, expected[0])) { + expected.shift(); + remaining.delete(event); + } + } + } else { + expected = expected.filter(val => { + for (let remainingEvent of remaining) { + if (compare(remainingEvent, val)) { + remaining.delete(remainingEvent); + return false; + } + } + return true; + }); + } + + // Events that did occur have been removed from expected so if + // expected is empty, we're done. If we didn't see all the + // expected events and we're not looking for an exact match, + // then we just may not have seen the event yet, so return without + // failing and check() will be called again when a new event arrives. + if (!expected.length) { + events = remaining; + eventWaiter = null; + resolve(); + } else if (exact) { + fail( + `Mismatched event: expecting ${JSON.stringify( + expected[0] + )} but got ${JSON.stringify(Array.from(remaining)[0])}` + ); + } + } + eventWaiter = check; + check(); + }); + } + + browser.test.onMessage.addListener(async (msg, ...args) => { + let match = msg.match(/(\w+).request$/); + if (!match) { + return; + } + + let what = match[1]; + if (what == "waitForEvents") { + try { + await waitForEvents(...args); + browser.test.sendMessage("waitForEvents.done", { status: "success" }); + } catch (error) { + browser.test.sendMessage("waitForEvents.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (what == "clearEvents") { + events = new Set(); + browser.test.sendMessage("clearEvents.done", { status: "success" }); + } else { + try { + let result = await browser.downloads[what](...args); + browser.test.sendMessage(`${what}.done`, { status: "success", result }); + } catch (error) { + browser.test.sendMessage(`${what}.done`, { + status: "error", + errmsg: error.message, + }); + } + } + }); + + browser.test.sendMessage("ready"); +} + +let downloadDir; +let extension; + +async function waitForCreatedPartFile(baseFilename = "interruptible.html") { + const partFilePath = `${downloadDir.path}/${baseFilename}.part`; + + info(`Wait for ${partFilePath} to be created`); + let lastError; + await TestUtils.waitForCondition( + async () => + OS.File.stat(partFilePath).then( + () => true, + err => { + lastError = err; + return false; + } + ), + `Wait for the ${partFilePath} to exists before pausing the download` + ).catch(err => { + if (lastError) { + throw lastError; + } + throw err; + }); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +function runInExtension(what, ...args) { + extension.sendMessage(`${what}.request`, ...args); + return extension.awaitMessage(`${what}.done`); +} + +// This is pretty simplistic, it looks for a progress update for a +// download of the given url in which the total bytes are exactly equal +// to the given value. Unless you know exactly how data will arrive from +// the server (eg see interruptible.sjs), it probably isn't very useful. +async function waitForProgress(url, testFn) { + let list = await Downloads.getList(Downloads.ALL); + + return new Promise(resolve => { + const view = { + onDownloadChanged(download) { + if (download.source.url == url && testFn(download.currentBytes)) { + list.removeView(view); + resolve(download.currentBytes); + } + }, + }; + list.addView(view); + }); +} + +add_task(async function setup() { + const nsIFile = Ci.nsIFile; + downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + downloadDir.remove(true); + + return clearDownloads(); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); +}); + +add_task(async function test_events() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onCreated and onChanged events"); +}); + +add_task(async function test_cancel() { + let url = getInterruptibleUrl(); + info(url); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("cancel", id); + equal(msg.status, "success", "cancel() succeeded"); + + // TODO bug 1256243: This sequence of events is bogus + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + { + type: "onChanged", + data: { + id, + paused: { + previous: true, + current: false, + }, + }, + }, + ]); + equal( + msg.status, + "success", + "got onChanged events corresponding to cancel()" + ); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause a canceled download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "error", "cannot resume a canceled download"); +}); + +add_task(async function test_pauseresume() { + const filename = "pauseresume.html"; + let url = getInterruptibleUrl(filename); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + // Prevent intermittent timeouts due to the part file not yet created + // (e.g. see Bug 1573360). + await waitForCreatedPartFile(filename); + + info("Pause the download item"); + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("search", { paused: true }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, true, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, true, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_PARTIAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + let found = msg.result.filter(item => item.id == id); + equal(found.length, 1, "search() by error found the paused download"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause an already paused download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "success", "resume() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "interrupted", + current: "in_progress", + }, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + error: { + previous: "USER_CANCELED", + current: null, + }, + }, + }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events for resume and complete"); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].state, "complete", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, null, "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_TOTAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, true, "download.exists is correct"); + + msg = await runInExtension("pause", id); + equal(msg.status, "error", "cannot pause a completed download"); + + msg = await runInExtension("resume", id); + equal(msg.status, "error", "cannot resume a completed download"); +}); + +add_task(async function test_pausecancel() { + let url = getInterruptibleUrl(); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("search", { paused: true }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].id, id, "download.id is correct"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, true, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, true, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].bytesReceived, + INT_PARTIAL_LEN, + "download.bytesReceived is correct" + ); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); + + msg = await runInExtension("search", { error: "USER_CANCELED" }); + equal(msg.status, "success", "search() succeeded"); + let found = msg.result.filter(item => item.id == id); + equal(found.length, 1, "search() by error found the paused download"); + + msg = await runInExtension("cancel", id); + equal(msg.status, "success", "cancel() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event for cancel"); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + equal(msg.result[0].state, "interrupted", "download.state is correct"); + equal(msg.result[0].paused, false, "download.paused is correct"); + equal( + msg.result[0].estimatedEndTime, + null, + "download.estimatedEndTime is correct" + ); + equal(msg.result[0].canResume, false, "download.canResume is correct"); + equal(msg.result[0].error, "USER_CANCELED", "download.error is correct"); + equal( + msg.result[0].totalBytes, + INT_TOTAL_LEN, + "download.totalBytes is correct" + ); + equal(msg.result[0].exists, false, "download.exists is correct"); +}); + +add_task(async function test_pause_resume_cancel_badargs() { + let BAD_ID = 1000; + + let msg = await runInExtension("pause", BAD_ID); + equal(msg.status, "error", "pause() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); + + msg = await runInExtension("resume", BAD_ID); + equal(msg.status, "error", "resume() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); + + msg = await runInExtension("cancel", BAD_ID); + equal(msg.status, "error", "cancel() failed with a bad download id"); + ok(/Invalid download id/.test(msg.errmsg), "error message is descriptive"); +}); + +add_task(async function test_file_removal() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + + equal(msg.status, "success", "got onCreated and onChanged events"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "success", "removeFile() succeeded"); + + msg = await runInExtension("removeFile", id); + equal( + msg.status, + "error", + "removeFile() fails since the file was already removed." + ); + ok( + /file doesn't exist/.test(msg.errmsg), + "removeFile() failed on removed file." + ); + + msg = await runInExtension("removeFile", 1000); + ok( + /Invalid download id/.test(msg.errmsg), + "removeFile() failed due to non-existent id" + ); +}); + +add_task(async function test_removal_of_incomplete_download() { + const filename = "remove-incomplete.html"; + let url = getInterruptibleUrl(filename); + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let progressPromise = waitForProgress(url, bytes => bytes == INT_PARTIAL_LEN); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id } }, + ]); + equal(msg.status, "success", "got created and changed events"); + + await progressPromise; + info(`download reached ${INT_PARTIAL_LEN} bytes`); + + // Prevent intermittent timeouts due to the part file not yet created + // (e.g. see Bug 1573360). + await waitForCreatedPartFile(filename); + + msg = await runInExtension("pause", id); + equal(msg.status, "success", "pause() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "interrupted", + }, + paused: { + previous: false, + current: true, + }, + canResume: { + previous: false, + current: true, + }, + }, + }, + { + type: "onChanged", + data: { + id, + error: { + previous: null, + current: "USER_CANCELED", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged event corresponding to pause"); + + msg = await runInExtension("removeFile", id); + equal(msg.status, "error", "removeFile() on paused download failed"); + + ok( + /Cannot remove incomplete download/.test(msg.errmsg), + "removeFile() failed due to download being incomplete" + ); + + msg = await runInExtension("resume", id); + equal(msg.status, "success", "resume() succeeded"); + + msg = await runInExtension("waitForEvents", [ + { + type: "onChanged", + data: { + id, + state: { + previous: "interrupted", + current: "in_progress", + }, + paused: { + previous: true, + current: false, + }, + canResume: { + previous: true, + current: false, + }, + error: { + previous: "USER_CANCELED", + current: null, + }, + }, + }, + { + type: "onChanged", + data: { + id, + state: { + previous: "in_progress", + current: "complete", + }, + }, + }, + ]); + equal(msg.status, "success", "got onChanged events for resume and complete"); + + msg = await runInExtension("removeFile", id); + equal( + msg.status, + "success", + "removeFile() succeeded following completion of resumed download." + ); +}); + +// Test erase(). We don't do elaborate testing of the query handling +// since it uses the exact same engine as search() which is tested +// more thoroughly in test_chrome_ext_downloads_search.html +add_task(async function test_erase() { + await clearDownloads(); + + await runInExtension("clearEvents"); + + async function download() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download succeeded"); + let id = msg.result; + + msg = await runInExtension( + "waitForEvents", + [ + { + type: "onChanged", + data: { id, state: { current: "complete" } }, + }, + ], + { exact: false } + ); + equal(msg.status, "success", "download finished"); + + return id; + } + + let ids = {}; + ids.dl1 = await download(); + ids.dl2 = await download(); + ids.dl3 = await download(); + + let msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 3, "search found 3 downloads"); + + msg = await runInExtension("clearEvents"); + + msg = await runInExtension("erase", { id: ids.dl1 }); + equal(msg.status, "success", "erase by id succeeded"); + + msg = await runInExtension("waitForEvents", [ + { type: "onErased", data: ids.dl1 }, + ]); + equal(msg.status, "success", "received onErased event"); + + msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 2, "search found 2 downloads"); + + msg = await runInExtension("erase", {}); + equal(msg.status, "success", "erase everything succeeded"); + + msg = await runInExtension( + "waitForEvents", + [ + { type: "onErased", data: ids.dl2 }, + { type: "onErased", data: ids.dl3 }, + ], + { inorder: false } + ); + equal(msg.status, "success", "received 2 onErased events"); + + msg = await runInExtension("search", {}); + equal(msg.status, "success", "search succeeded"); + equal(msg.result.length, 0, "search found 0 downloads"); +}); + +function loadImage(img, data) { + return new Promise(resolve => { + img.src = data; + img.onload = resolve; + }); +} + +add_task(async function test_getFileIcon() { + let webNav = Services.appShell.createWindowlessBrowser(false); + let docShell = webNav.docShell; + + let system = Services.scriptSecurityManager.getSystemPrincipal(); + docShell.createAboutBlankContentViewer(system, system); + + let img = webNav.document.createElement("img"); + + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + msg = await runInExtension("getFileIcon", id); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 32, "returns an icon with the right height"); + equal(img.width, 32, "returns an icon with the right width"); + + msg = await runInExtension("waitForEvents", [ + { type: "onCreated", data: { id, url: TXT_URL } }, + { type: "onChanged" }, + ]); + equal(msg.status, "success", "got events"); + + msg = await runInExtension("getFileIcon", id); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 32, "returns an icon with the right height after download"); + equal(img.width, 32, "returns an icon with the right width after download"); + + msg = await runInExtension("getFileIcon", id + 100); + equal(msg.status, "error", "getFileIcon() failed"); + ok(msg.errmsg.includes("Invalid download id"), "download id is invalid"); + + msg = await runInExtension("getFileIcon", id, { size: 127 }); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 127, "returns an icon with the right custom height"); + equal(img.width, 127, "returns an icon with the right custom width"); + + msg = await runInExtension("getFileIcon", id, { size: 1 }); + equal(msg.status, "success", "getFileIcon() succeeded"); + await loadImage(img, msg.result); + equal(img.height, 1, "returns an icon with the right custom height"); + equal(img.width, 1, "returns an icon with the right custom width"); + + msg = await runInExtension("getFileIcon", id, { size: "foo" }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is not a number"); + + msg = await runInExtension("getFileIcon", id, { size: 0 }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is too small"); + + msg = await runInExtension("getFileIcon", id, { size: 128 }); + equal(msg.status, "error", "getFileIcon() fails"); + ok(msg.errmsg.includes("Error processing size"), "size is too big"); + + webNav.close(); +}); + +add_task(async function test_estimatedendtime() { + // Note we are not testing the actual value calculation of estimatedEndTime, + // only whether it is null/non-null at the appropriate times. + + let url = `${getInterruptibleUrl()}&stream=1`; + let msg = await runInExtension("download", { url }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + + let previousBytes = await waitForProgress(url, bytes => bytes > 0); + await waitForProgress(url, bytes => bytes > previousBytes); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + ok(msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct"); + ok(msg.result[0].bytesReceived > 0, "download.bytesReceived is correct"); + + msg = await runInExtension("cancel", id); + + msg = await runInExtension("search", { id }); + equal(msg.status, "success", "search() succeeded"); + equal(msg.result.length, 1, "search() found 1 download"); + ok(!msg.result[0].estimatedEndTime, "download.estimatedEndTime is correct"); +}); + +add_task(async function test_byExtension() { + let msg = await runInExtension("download", { url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + const id = msg.result; + msg = await runInExtension("search", { id }); + + equal(msg.result.length, 1, "search() found 1 download"); + equal( + msg.result[0].byExtensionName, + "Generated extension", + "download.byExtensionName is correct" + ); + equal( + msg.result[0].byExtensionId, + extension.id, + "download.byExtensionId is correct" + ); +}); + +add_task(async function cleanup() { + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js new file mode 100644 index 0000000000..b80e5f3274 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_private.js @@ -0,0 +1,308 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; + +add_task(function setup() { + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique( + Ci.nsIFile.DIRECTORY_TYPE, + FileUtils.PERMS_DIRECTORY + ); + info(`Using download directory ${downloadDir.path}`); + + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue( + "browser.download.dir", + Ci.nsIFile, + downloadDir + ); + + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + + let entries = downloadDir.directoryEntries; + while (entries.hasMoreElements()) { + let entry = entries.nextFile; + ok(false, `Leftover file ${entry.path} in download directory`); + entry.remove(false); + } + + downloadDir.remove(false); + }); +}); + +add_task(async function test_private_download() { + let pb_extension = ExtensionTestUtils.loadExtension({ + background: async function() { + function promiseEvent(eventTarget, accept) { + return new Promise(resolve => { + eventTarget.addListener(function listener(data) { + if (accept && !accept(data)) { + return; + } + eventTarget.removeListener(listener); + resolve(data); + }); + }); + } + let startTestPromise = promiseEvent(browser.test.onMessage); + let removeTestPromise = promiseEvent( + browser.test.onMessage, + msg => msg == "remove" + ); + let onCreatedPromise = promiseEvent(browser.downloads.onCreated); + let onDonePromise = promiseEvent( + browser.downloads.onChanged, + delta => delta.state && delta.state.current === "complete" + ); + + browser.test.sendMessage("ready"); + let { url, filename } = await startTestPromise; + + browser.test.log("Starting private download"); + let downloadId = await browser.downloads.download({ + url, + filename, + incognito: true, + }); + browser.test.sendMessage("downloadId", downloadId); + + browser.test.log("Waiting for downloads.onCreated"); + let createdItem = await onCreatedPromise; + + browser.test.log("Waiting for completion notification"); + await onDonePromise; + + // test_ext_downloads_download.js already tests whether the file exists + // in the file system. Here we will only verify that the downloads API + // behaves in a meaningful way. + + let [downloadItem] = await browser.downloads.search({ id: downloadId }); + browser.test.assertEq(url, createdItem.url, "onCreated url should match"); + browser.test.assertEq(url, downloadItem.url, "download url should match"); + browser.test.assertTrue( + createdItem.incognito, + "created download should be private" + ); + browser.test.assertTrue( + downloadItem.incognito, + "stored download should be private" + ); + + await removeTestPromise; + browser.test.log("Removing downloaded file"); + browser.test.assertTrue(downloadItem.exists, "downloaded file exists"); + await browser.downloads.removeFile(downloadId); + + // Disabled because the assertion fails - https://bugzil.la/1381031 + // let [downloadItem2] = await browser.downloads.search({id: downloadId}); + // browser.test.assertFalse(downloadItem2.exists, "file should be deleted"); + + browser.test.log("Erasing private download from history"); + let erasePromise = promiseEvent(browser.downloads.onErased); + await browser.downloads.erase({ id: downloadId }); + browser.test.assertEq( + downloadId, + await erasePromise, + "onErased should be fired for the erased private download" + ); + + browser.test.notifyPass("private download test done"); + }, + manifest: { + applications: { gecko: { id: "@spanning" } }, + permissions: ["downloads"], + }, + incognitoOverride: "spanning", + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "@not_allowed" } }, + permissions: ["downloads", "downloads.open"], + }, + background: async function() { + browser.downloads.onCreated.addListener(() => { + browser.test.fail("download-onCreated"); + }); + browser.downloads.onChanged.addListener(() => { + browser.test.fail("download-onChanged"); + }); + browser.downloads.onErased.addListener(() => { + browser.test.fail("download-onErased"); + }); + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "download") { + let { url, filename, downloadId } = data; + await browser.test.assertRejects( + browser.downloads.download({ + url, + filename, + incognito: true, + }), + /private browsing access not allowed/, + "cannot download using incognito without permission." + ); + + let downloads = await browser.downloads.search({ id: downloadId }); + browser.test.assertEq( + downloads.length, + 0, + "cannot search for incognito downloads" + ); + let erasing = await browser.downloads.erase({ id: downloadId }); + browser.test.assertEq( + erasing.length, + 0, + "cannot erase incognito download" + ); + + await browser.test.assertRejects( + browser.downloads.removeFile(downloadId), + /Invalid download id/, + "cannot remove incognito download" + ); + await browser.test.assertRejects( + browser.downloads.pause(downloadId), + /Invalid download id/, + "cannot pause incognito download" + ); + await browser.test.assertRejects( + browser.downloads.resume(downloadId), + /Invalid download id/, + "cannot resume incognito download" + ); + await browser.test.assertRejects( + browser.downloads.cancel(downloadId), + /Invalid download id/, + "cannot cancel incognito download" + ); + await browser.test.assertRejects( + browser.downloads.removeFile(downloadId), + /Invalid download id/, + "cannot remove incognito download" + ); + await browser.test.assertRejects( + browser.downloads.show(downloadId), + /Invalid download id/, + "cannot show incognito download" + ); + await browser.test.assertRejects( + browser.downloads.getFileIcon(downloadId), + /Invalid download id/, + "cannot show incognito download" + ); + } + if (msg == "download.open") { + let { downloadId } = data; + await browser.test.assertRejects( + browser.downloads.open(downloadId), + /Invalid download id/, + "cannot open incognito download" + ); + } + browser.test.sendMessage("continue"); + }); + }, + }); + + await extension.startup(); + await pb_extension.startup(); + await pb_extension.awaitMessage("ready"); + pb_extension.sendMessage({ + url: TXT_URL, + filename: TXT_FILE, + }); + let downloadId = await pb_extension.awaitMessage("downloadId"); + extension.sendMessage("download", { + url: TXT_URL, + filename: TXT_FILE, + downloadId, + }); + await extension.awaitMessage("continue"); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("download.open", { downloadId }); + await extension.awaitMessage("continue"); + }); + pb_extension.sendMessage("remove"); + + await pb_extension.awaitFinish("private download test done"); + await pb_extension.unload(); + await extension.unload(); +}); + +// Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1649463 +add_task(async function download_blob_in_perma_private_browsing() { + Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + + // This script creates a blob:-URL and checks that the URL can be downloaded. + async function testScript() { + const blobUrl = URL.createObjectURL(new Blob(["data here"])); + const downloadId = await new Promise(resolve => { + browser.downloads.onChanged.addListener(delta => { + browser.test.log(`downloads.onChanged = ${JSON.stringify(delta)}`); + if (delta.state && delta.state.current !== "in_progress") { + resolve(delta.id); + } + }); + browser.downloads.download({ + url: blobUrl, + filename: "some-blob-download.txt", + }); + }); + + let [downloadItem] = await browser.downloads.search({ id: downloadId }); + browser.test.log(`Downloaded ${JSON.stringify(downloadItem)}`); + browser.test.assertEq(downloadItem.url, blobUrl, "expected blob URL"); + // TODO bug 1653636: should be true because of perma-private browsing. + // browser.test.assertTrue(downloadItem.incognito, "download is private"); + browser.test.assertFalse( + downloadItem.incognito, + "download is private [skipped - to be fixed in bug 1653636]" + ); + browser.test.assertTrue(downloadItem.exists, "download exists"); + await browser.downloads.removeFile(downloadId); + + browser.test.sendMessage("downloadDone"); + } + let pb_extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "@private-download-ext" } }, + permissions: ["downloads"], + }, + background: testScript, + incognitoOverride: "spanning", + files: { + "test_part2.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="test_part2.js"></script> + `, + "test_part2.js": testScript, + }, + }); + await pb_extension.startup(); + + info("Testing download of blob:-URL from extension's background page"); + await pb_extension.awaitMessage("downloadDone"); + + info("Testing download of blob:-URL with different userContextId"); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${pb_extension.uuid}/test_part2.html`, + { extension: pb_extension, userContextId: 2 } + ); + await pb_extension.awaitMessage("downloadDone"); + await contentPage.close(); + + await pb_extension.unload(); + Services.prefs.clearUserPref("browser.privatebrowsing.autostart"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js new file mode 100644 index 0000000000..f28a4c881f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_search.js @@ -0,0 +1,682 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.import( + "resource://gre/modules/Downloads.jsm" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = `http://localhost:${server.identity.primaryPort}/data`; +const TXT_FILE = "file_download.txt"; +const TXT_URL = BASE + "/" + TXT_FILE; +const TXT_LEN = 46; +const HTML_FILE = "file_download.html"; +const HTML_URL = BASE + "/" + HTML_FILE; +const HTML_LEN = 117; +const EMPTY_FILE = "empty_file_download.txt"; +const EMPTY_URL = BASE + "/" + EMPTY_FILE; +const EMPTY_LEN = 0; +const BIG_LEN = 1000; // something bigger both TXT_LEN and HTML_LEN + +function backgroundScript() { + let complete = new Map(); + + function waitForComplete(id) { + if (complete.has(id)) { + return complete.get(id).promise; + } + + let promise = new Promise(resolve => { + complete.set(id, { resolve }); + }); + complete.get(id).promise = promise; + return promise; + } + + browser.downloads.onChanged.addListener(change => { + if (change.state && change.state.current == "complete") { + // Make sure we have a promise. + waitForComplete(change.id); + complete.get(change.id).resolve(); + } + }); + + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + try { + let id = await browser.downloads.download(args[0]); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "search.request") { + try { + let downloads = await browser.downloads.search(args[0]); + browser.test.sendMessage("search.done", { + status: "success", + downloads, + }); + } catch (error) { + browser.test.sendMessage("search.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "waitForComplete.request") { + await waitForComplete(args[0]); + browser.test.sendMessage("waitForComplete.done"); + } + }); + + browser.test.sendMessage("ready"); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +add_task(async function test_search() { + const nsIFile = Ci.nsIFile; + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + function downloadPath(filename) { + let path = downloadDir.clone(); + path.append(filename); + return path.path; + } + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + Services.prefs.setBoolPref("privacy.reduceTimerPrecision", false); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + Services.prefs.clearUserPref("privacy.reduceTimerPrecision"); + await cleanupDir(downloadDir); + await clearDownloads(); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + async function download(options) { + extension.sendMessage("download.request", options); + let result = await extension.awaitMessage("download.done"); + + if (result.status == "success") { + info(`wait for onChanged event to indicate ${result.id} is complete`); + extension.sendMessage("waitForComplete.request", result.id); + + await extension.awaitMessage("waitForComplete.done"); + } + + return result; + } + + function search(query) { + extension.sendMessage("search.request", query); + return extension.awaitMessage("search.done"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Do some downloads... + const time1 = new Date(); + + let downloadIds = {}; + let msg = await download({ url: TXT_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt1 = msg.id; + + const TXT_FILE2 = "NewFile.txt"; + msg = await download({ url: TXT_URL, filename: TXT_FILE2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt2 = msg.id; + + msg = await download({ url: EMPTY_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.txt3 = msg.id; + + const time2 = new Date(); + + msg = await download({ url: HTML_URL }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.html1 = msg.id; + + const HTML_FILE2 = "renamed.html"; + msg = await download({ url: HTML_URL, filename: HTML_FILE2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.html2 = msg.id; + + const time3 = new Date(); + + // Search for each individual download and check + // the corresponding DownloadItem. + async function checkDownloadItem(id, expect) { + let item = await search({ id }); + equal(item.status, "success", "search() succeeded"); + equal(item.downloads.length, 1, "search() found exactly 1 download"); + + Object.keys(expect).forEach(function(field) { + equal( + item.downloads[0][field], + expect[field], + `DownloadItem.${field} is correct"` + ); + }); + } + await checkDownloadItem(downloadIds.txt1, { + url: TXT_URL, + filename: downloadPath(TXT_FILE), + mime: "text/plain", + state: "complete", + bytesReceived: TXT_LEN, + totalBytes: TXT_LEN, + fileSize: TXT_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.txt2, { + url: TXT_URL, + filename: downloadPath(TXT_FILE2), + mime: "text/plain", + state: "complete", + bytesReceived: TXT_LEN, + totalBytes: TXT_LEN, + fileSize: TXT_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.txt3, { + url: EMPTY_URL, + filename: downloadPath(EMPTY_FILE), + mime: "text/plain", + state: "complete", + bytesReceived: EMPTY_LEN, + totalBytes: EMPTY_LEN, + fileSize: EMPTY_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.html1, { + url: HTML_URL, + filename: downloadPath(HTML_FILE), + mime: "text/html", + state: "complete", + bytesReceived: HTML_LEN, + totalBytes: HTML_LEN, + fileSize: HTML_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.html2, { + url: HTML_URL, + filename: downloadPath(HTML_FILE2), + mime: "text/html", + state: "complete", + bytesReceived: HTML_LEN, + totalBytes: HTML_LEN, + fileSize: HTML_LEN, + exists: true, + }); + + async function checkSearch(query, expected, description, exact) { + let item = await search(query); + equal(item.status, "success", "search() succeeded"); + equal( + item.downloads.length, + expected.length, + `search() for ${description} found exactly ${expected.length} downloads` + ); + + let receivedIds = item.downloads.map(i => i.id); + if (exact) { + receivedIds.forEach((id, idx) => { + equal( + id, + downloadIds[expected[idx]], + `search() for ${description} returned ${expected[idx]} in position ${idx}` + ); + }); + } else { + Object.keys(downloadIds).forEach(key => { + const id = downloadIds[key]; + const thisExpected = expected.includes(key); + equal( + receivedIds.includes(id), + thisExpected, + `search() for ${description} ${ + thisExpected ? "includes" : "does not include" + } ${key}` + ); + }); + } + } + + // Check that search with an invalid id returns nothing. + // NB: for now ids are not persistent and we start numbering them at 1 + // so a sufficiently large number will be unused. + const INVALID_ID = 1000; + await checkSearch({ id: INVALID_ID }, [], "invalid id"); + + // Check that search on url works. + await checkSearch({ url: TXT_URL }, ["txt1", "txt2"], "url"); + + // Check that regexp on url works. + const HTML_REGEX = "[download]{8}.html+$"; + await checkSearch({ urlRegex: HTML_REGEX }, ["html1", "html2"], "url regexp"); + + // Check that compatible url+regexp works + await checkSearch( + { url: HTML_URL, urlRegex: HTML_REGEX }, + ["html1", "html2"], + "compatible url+urlRegex" + ); + + // Check that incompatible url+regexp works + await checkSearch( + { url: TXT_URL, urlRegex: HTML_REGEX }, + [], + "incompatible url+urlRegex" + ); + + // Check that search on filename works. + await checkSearch({ filename: downloadPath(TXT_FILE) }, ["txt1"], "filename"); + + // Check that regexp on filename works. + await checkSearch({ filenameRegex: HTML_REGEX }, ["html1"], "filename regex"); + + // Check that compatible filename+regexp works + await checkSearch( + { filename: downloadPath(HTML_FILE), filenameRegex: HTML_REGEX }, + ["html1"], + "compatible filename+filename regex" + ); + + // Check that incompatible filename+regexp works + await checkSearch( + { filename: downloadPath(TXT_FILE), filenameRegex: HTML_REGEX }, + [], + "incompatible filename+filename regex" + ); + + // Check that simple positive search terms work. + await checkSearch( + { query: ["file_download"] }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "term file_download" + ); + await checkSearch({ query: ["NewFile"] }, ["txt2"], "term NewFile"); + + // Check that positive search terms work case-insensitive. + await checkSearch({ query: ["nEwfILe"] }, ["txt2"], "term nEwfiLe"); + + // Check that negative search terms work. + await checkSearch({ query: ["-txt"] }, ["html1", "html2"], "term -txt"); + + // Check that positive and negative search terms together work. + await checkSearch( + { query: ["html", "-renamed"] }, + ["html1"], + "positive and negative terms" + ); + + async function checkSearchWithDate(query, expected, description) { + const fields = Object.keys(query); + if (fields.length != 1 || !(query[fields[0]] instanceof Date)) { + throw new Error("checkSearchWithDate expects exactly one Date field"); + } + const field = fields[0]; + const date = query[field]; + + let newquery = {}; + + // Check as a Date + newquery[field] = date; + await checkSearch(newquery, expected, `${description} as Date`); + + // Check as numeric milliseconds + newquery[field] = date.valueOf(); + await checkSearch(newquery, expected, `${description} as numeric ms`); + + // Check as stringified milliseconds + newquery[field] = date.valueOf().toString(); + await checkSearch(newquery, expected, `${description} as string ms`); + + // Check as ISO string + newquery[field] = date.toISOString(); + await checkSearch(newquery, expected, `${description} as iso string`); + } + + // Check startedBefore + await checkSearchWithDate({ startedBefore: time1 }, [], "before time1"); + await checkSearchWithDate( + { startedBefore: time2 }, + ["txt1", "txt2", "txt3"], + "before time2" + ); + await checkSearchWithDate( + { startedBefore: time3 }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "before time3" + ); + + // Check startedAfter + await checkSearchWithDate( + { startedAfter: time1 }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "after time1" + ); + await checkSearchWithDate( + { startedAfter: time2 }, + ["html1", "html2"], + "after time2" + ); + await checkSearchWithDate({ startedAfter: time3 }, [], "after time3"); + + // Check simple search on totalBytes + await checkSearch({ totalBytes: TXT_LEN }, ["txt1", "txt2"], "totalBytes"); + await checkSearch({ totalBytes: HTML_LEN }, ["html1", "html2"], "totalBytes"); + + // Check simple test on totalBytes{Greater,Less} + // (NB: TXT_LEN < HTML_LEN < BIG_LEN) + await checkSearch( + { totalBytesGreater: 0 }, + ["txt1", "txt2", "html1", "html2"], + "totalBytesGreater than 0" + ); + await checkSearch( + { totalBytesGreater: TXT_LEN }, + ["html1", "html2"], + `totalBytesGreater than ${TXT_LEN}` + ); + await checkSearch( + { totalBytesGreater: HTML_LEN }, + [], + `totalBytesGreater than ${HTML_LEN}` + ); + await checkSearch( + { totalBytesLess: TXT_LEN }, + ["txt3"], + `totalBytesLess than ${TXT_LEN}` + ); + await checkSearch( + { totalBytesLess: HTML_LEN }, + ["txt1", "txt2", "txt3"], + `totalBytesLess than ${HTML_LEN}` + ); + await checkSearch( + { totalBytesLess: BIG_LEN }, + ["txt1", "txt2", "txt3", "html1", "html2"], + `totalBytesLess than ${BIG_LEN}` + ); + + // Bug 1503760 check if 0 byte files with no search query are returned. + await checkSearch( + {}, + ["txt1", "txt2", "txt3", "html1", "html2"], + "totalBytesGreater than -1" + ); + + // Check good combinations of totalBytes*. + await checkSearch( + { totalBytes: HTML_LEN, totalBytesGreater: TXT_LEN }, + ["html1", "html2"], + "totalBytes and totalBytesGreater" + ); + await checkSearch( + { totalBytes: TXT_LEN, totalBytesLess: HTML_LEN }, + ["txt1", "txt2"], + "totalBytes and totalBytesGreater" + ); + await checkSearch( + { totalBytes: HTML_LEN, totalBytesLess: BIG_LEN, totalBytesGreater: 0 }, + ["html1", "html2"], + "totalBytes and totalBytesLess and totalBytesGreater" + ); + + // Check bad combination of totalBytes*. + await checkSearch( + { totalBytesLess: TXT_LEN, totalBytesGreater: HTML_LEN }, + [], + "bad totalBytesLess, totalBytesGreater combination" + ); + await checkSearch( + { totalBytes: TXT_LEN, totalBytesGreater: HTML_LEN }, + [], + "bad totalBytes, totalBytesGreater combination" + ); + await checkSearch( + { totalBytes: HTML_LEN, totalBytesLess: TXT_LEN }, + [], + "bad totalBytes, totalBytesLess combination" + ); + + // Check mime. + await checkSearch( + { mime: "text/plain" }, + ["txt1", "txt2", "txt3"], + "mime text/plain" + ); + await checkSearch( + { mime: "text/html" }, + ["html1", "html2"], + "mime text/htmlplain" + ); + await checkSearch({ mime: "video/webm" }, [], "mime video/webm"); + + // Check fileSize. + await checkSearch({ fileSize: TXT_LEN }, ["txt1", "txt2"], "fileSize"); + await checkSearch({ fileSize: HTML_LEN }, ["html1", "html2"], "fileSize"); + + // Fields like bytesReceived, paused, state, exists are meaningful + // for downloads that are in progress but have not yet completed. + // todo: add tests for these when we have better support for in-progress + // downloads (e.g., after pause(), resume() and cancel() are implemented) + + // Check multiple query properties. + // We could make this testing arbitrarily complicated... + // We already tested combining fields with obvious interactions above + // (e.g., filename and filenameRegex or startTime and startedBefore/After) + // so now just throw as many fields as we can at a single search and + // make sure a simple case still works. + await checkSearch( + { + url: TXT_URL, + urlRegex: "download", + filename: downloadPath(TXT_FILE), + filenameRegex: "download", + query: ["download"], + startedAfter: time1.valueOf().toString(), + startedBefore: time2.valueOf().toString(), + totalBytes: TXT_LEN, + totalBytesGreater: 0, + totalBytesLess: BIG_LEN, + mime: "text/plain", + fileSize: TXT_LEN, + }, + ["txt1"], + "many properties" + ); + + // Check simple orderBy (forward and backward). + await checkSearch( + { orderBy: ["startTime"] }, + ["txt1", "txt2", "txt3", "html1", "html2"], + "orderBy startTime", + true + ); + await checkSearch( + { orderBy: ["-startTime"] }, + ["html2", "html1", "txt3", "txt2", "txt1"], + "orderBy -startTime", + true + ); + + // Check orderBy with multiple fields. + // NB: TXT_URL and HTML_URL differ only in extension and .html precedes .txt + // EMPTY_URL begins with e which precedes f + await checkSearch( + { orderBy: ["url", "-startTime"] }, + ["txt3", "html2", "html1", "txt2", "txt1"], + "orderBy with multiple fields", + true + ); + + // Check orderBy with limit. + await checkSearch( + { orderBy: ["url"], limit: 1 }, + ["txt3"], + "orderBy with limit", + true + ); + + // Check bad arguments. + async function checkBadSearch(query, pattern, description) { + let item = await search(query); + equal(item.status, "error", "search() failed"); + ok( + pattern.test(item.errmsg), + `error message for ${description} was correct (${item.errmsg}).` + ); + } + + await checkBadSearch( + "myquery", + /Incorrect argument type/, + "query is not an object" + ); + await checkBadSearch( + { bogus: "boo" }, + /Unexpected property/, + "query contains an unknown field" + ); + await checkBadSearch( + { query: "query string" }, + /Expected array/, + "query.query is a string" + ); + await checkBadSearch( + { startedBefore: "i am not a time" }, + /Type error/, + "query.startedBefore is not a valid time" + ); + await checkBadSearch( + { startedAfter: "i am not a time" }, + /Type error/, + "query.startedAfter is not a valid time" + ); + await checkBadSearch( + { endedBefore: "i am not a time" }, + /Type error/, + "query.endedBefore is not a valid time" + ); + await checkBadSearch( + { endedAfter: "i am not a time" }, + /Type error/, + "query.endedAfter is not a valid time" + ); + await checkBadSearch( + { urlRegex: "[" }, + /Invalid urlRegex/, + "query.urlRegexp is not a valid regular expression" + ); + await checkBadSearch( + { filenameRegex: "[" }, + /Invalid filenameRegex/, + "query.filenameRegexp is not a valid regular expression" + ); + await checkBadSearch( + { orderBy: "startTime" }, + /Expected array/, + "query.orderBy is not an array" + ); + await checkBadSearch( + { orderBy: ["bogus"] }, + /Invalid orderBy field/, + "query.orderBy references a non-existent field" + ); + + await extension.unload(); +}); + +// Test that downloads with totalBytes of -1 (ie, that have not yet started) +// work properly. See bug 1519762 for details of a past regression in +// this area. +add_task(async function test_inprogress() { + let resume, + resumePromise = new Promise(resolve => { + resume = resolve; + }); + let hit = false; + server.registerPathHandler("/data/slow", async (request, response) => { + hit = true; + response.processAsync(); + await resumePromise; + response.setHeader("Content-type", "text/plain"); + response.write(""); + response.finish(); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["downloads"], + }, + background() { + browser.test.onMessage.addListener(async (msg, url) => { + let id = await browser.downloads.download({ url }); + let full = await browser.downloads.search({ id }); + + browser.test.assertEq( + full.length, + 1, + "Found new download in search results" + ); + browser.test.assertEq( + full[0].totalBytes, + -1, + "New download still has totalBytes == -1" + ); + + browser.downloads.onChanged.addListener(info => { + if (info.id == id && info.state && info.state.current == "complete") { + browser.test.notifyPass("done"); + } + }); + + browser.test.sendMessage("started"); + }); + }, + }); + + await extension.startup(); + extension.sendMessage("go", `${BASE}/slow`); + await extension.awaitMessage("started"); + resume(); + await extension.awaitFinish("done"); + await extension.unload(); + Assert.ok(hit, "slow path was actually hit"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js new file mode 100644 index 0000000000..9a63369efb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_downloads_urlencoded.js @@ -0,0 +1,235 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { Downloads } = ChromeUtils.import( + "resource://gre/modules/Downloads.jsm" +); + +function backgroundScript() { + let complete = new Map(); + + function waitForComplete(id) { + if (complete.has(id)) { + return complete.get(id).promise; + } + + let promise = new Promise(resolve => { + complete.set(id, { resolve }); + }); + complete.get(id).promise = promise; + return promise; + } + + browser.downloads.onChanged.addListener(change => { + if (change.state && change.state.current == "complete") { + // Make sure we have a promise. + waitForComplete(change.id); + complete.get(change.id).resolve(); + } + }); + + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg == "download.request") { + try { + let id = await browser.downloads.download(args[0]); + browser.test.sendMessage("download.done", { status: "success", id }); + } catch (error) { + browser.test.sendMessage("download.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "search.request") { + try { + let downloads = await browser.downloads.search(args[0]); + browser.test.sendMessage("search.done", { + status: "success", + downloads, + }); + } catch (error) { + browser.test.sendMessage("search.done", { + status: "error", + errmsg: error.message, + }); + } + } else if (msg == "waitForComplete.request") { + await waitForComplete(args[0]); + browser.test.sendMessage("waitForComplete.done"); + } + }); + + browser.test.sendMessage("ready"); +} + +async function clearDownloads(callback) { + let list = await Downloads.getList(Downloads.ALL); + let downloads = await list.getAll(); + + await Promise.all(downloads.map(download => list.remove(download))); + + return downloads; +} + +add_task(async function test_decoded_filename_download() { + const server = createHttpServer(); + server.registerPrefixHandler("/data/", (_, res) => res.write("length=8")); + + const BASE = `http://localhost:${server.identity.primaryPort}/data`; + const FILE_NAME_ENCODED_1 = "file%2Fencode.txt"; + const FILE_NAME_DECODED_1 = "file_encode.txt"; + const FILE_NAME_ENCODED_URL_1 = BASE + "/" + FILE_NAME_ENCODED_1; + const FILE_NAME_ENCODED_2 = "file%F0%9F%9A%B2encoded.txt"; + const FILE_NAME_DECODED_2 = "file\u{0001F6B2}encoded.txt"; + const FILE_NAME_ENCODED_URL_2 = BASE + "/" + FILE_NAME_ENCODED_2; + const FILE_NAME_ENCODED_3 = "file%X%20encode.txt"; + const FILE_NAME_DECODED_3 = "file%X encode.txt"; + const FILE_NAME_ENCODED_URL_3 = BASE + "/" + FILE_NAME_ENCODED_3; + const FILE_ENCODED_LEN = 8; + + const nsIFile = Ci.nsIFile; + let downloadDir = FileUtils.getDir("TmpD", ["downloads"]); + downloadDir.createUnique(nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + info(`downloadDir ${downloadDir.path}`); + + function downloadPath(filename) { + let path = downloadDir.clone(); + path.append(filename); + return path.path; + } + + Services.prefs.setIntPref("browser.download.folderList", 2); + Services.prefs.setComplexValue("browser.download.dir", nsIFile, downloadDir); + + registerCleanupFunction(async () => { + Services.prefs.clearUserPref("browser.download.folderList"); + Services.prefs.clearUserPref("browser.download.dir"); + await cleanupDir(downloadDir); + await clearDownloads(); + }); + + await clearDownloads().then(downloads => { + info(`removed ${downloads.length} pre-existing downloads from history`); + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["downloads"], + }, + }); + + async function download(options) { + extension.sendMessage("download.request", options); + let result = await extension.awaitMessage("download.done"); + + if (result.status == "success") { + info(`wait for onChanged event to indicate ${result.id} is complete`); + extension.sendMessage("waitForComplete.request", result.id); + + await extension.awaitMessage("waitForComplete.done"); + } + + return result; + } + + function search(query) { + extension.sendMessage("search.request", query); + return extension.awaitMessage("search.done"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + let downloadIds = {}; + let msg = await download({ url: FILE_NAME_ENCODED_URL_1 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded1 = msg.id; + + msg = await download({ url: FILE_NAME_ENCODED_URL_2 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded2 = msg.id; + + msg = await download({ url: FILE_NAME_ENCODED_URL_3 }); + equal(msg.status, "success", "download() succeeded"); + downloadIds.fileEncoded3 = msg.id; + + // Search for each individual download and check + // the corresponding DownloadItem. + async function checkDownloadItem(id, expect) { + let item = await search({ id }); + equal(item.status, "success", "search() succeeded"); + equal(item.downloads.length, 1, "search() found exactly 1 download"); + Object.keys(expect).forEach(function(field) { + equal( + item.downloads[0][field], + expect[field], + `DownloadItem.${field} is correct"` + ); + }); + } + + await checkDownloadItem(downloadIds.fileEncoded1, { + url: FILE_NAME_ENCODED_URL_1, + filename: downloadPath(FILE_NAME_DECODED_1), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.fileEncoded2, { + url: FILE_NAME_ENCODED_URL_2, + filename: downloadPath(FILE_NAME_DECODED_2), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + await checkDownloadItem(downloadIds.fileEncoded3, { + url: FILE_NAME_ENCODED_URL_3, + filename: downloadPath(FILE_NAME_DECODED_3), + state: "complete", + bytesReceived: FILE_ENCODED_LEN, + totalBytes: FILE_ENCODED_LEN, + fileSize: FILE_ENCODED_LEN, + exists: true, + }); + + // Searching for downloads by the decoded filename works correctly. + async function checkSearch(query, expected, description) { + let item = await search(query); + equal(item.status, "success", "search() succeeded"); + equal( + item.downloads.length, + expected.length, + `search() for ${description} found exactly ${expected.length} downloads` + ); + equal( + item.downloads[0].id, + downloadIds[expected[0]], + `search() for ${description} returned ${expected[0]} in position ${0}` + ); + } + + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_1) }, + ["fileEncoded1"], + "filename" + ); + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_2) }, + ["fileEncoded2"], + "filename" + ); + await checkSearch( + { filename: downloadPath(FILE_NAME_DECODED_3) }, + ["fileEncoded3"], + "filename" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js new file mode 100644 index 0000000000..ab18c9c371 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_error_location.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_error_location() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let { fileName } = new Error(); + + browser.test.sendMessage("fileName", fileName); + + browser.runtime.sendMessage("Meh.", () => {}); + + await browser.test.assertRejects( + browser.runtime.sendMessage("Meh"), + error => { + return error.fileName === fileName && error.lineNumber === 9; + } + ); + + browser.test.notifyPass("error-location"); + }, + }); + + let fileName; + const { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + + fileName = await extension.awaitMessage("fileName"); + + await extension.awaitFinish("error-location"); + + await extension.unload(); + }); + + let [msg] = messages.filter(m => m.message.includes("Unchecked lastError")); + + equal(msg.sourceName, fileName, "Message source"); + equal(msg.lineNumber, 6, "Message line"); + + let frame = msg.stack; + if (frame) { + equal(frame.source, fileName, "Frame source"); + equal(frame.line, 6, "Frame line"); + equal(frame.column, 23, "Frame column"); + equal(frame.functionDisplayName, "background", "Frame function name"); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js new file mode 100644 index 0000000000..ba53803f43 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_eventpage_warning.js @@ -0,0 +1,90 @@ +"use strict"; + +AddonTestUtils.init(this); +// This test expects and checks deprecation warnings. +ExtensionTestUtils.failOnSchemaWarnings(false); + +function createEventPageExtension(eventPage) { + return ExtensionTestUtils.loadExtension({ + manifest: { + background: eventPage, + }, + files: { + "event_page_script.js"() { + browser.test.log("running event page as background script"); + browser.test.sendMessage("running", 1); + }, + "event-page.html": `<!DOCTYPE html> + <html><head> + <meta charset="utf-8"> + <script src="event_page_script.js"><\/script> + </head></html>`, + }, + }); +} + +add_task(async function test_eventpages() { + let testCases = [ + { + message: "testing event page running as a background page", + eventPage: { + page: "event-page.html", + persistent: false, + }, + }, + { + message: "testing event page scripts running as a background page", + eventPage: { + scripts: ["event_page_script.js"], + persistent: false, + }, + }, + { + message: "testing additional unrecognized properties on background page", + eventPage: { + scripts: ["event_page_script.js"], + nonExistentProp: true, + }, + }, + { + message: "testing persistent background page", + eventPage: { + page: "event-page.html", + persistent: true, + }, + }, + { + message: + "testing scripts with persistent background running as a background page", + eventPage: { + scripts: ["event_page_script.js"], + persistent: true, + }, + }, + ]; + + let { messages } = await promiseConsoleOutput(async () => { + for (let test of testCases) { + info(test.message); + + let extension = createEventPageExtension(test.eventPage); + await extension.startup(); + let x = await extension.awaitMessage("running"); + equal(x, 1, "got correct value from extension"); + await extension.unload(); + } + }); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { message: /Event pages are not currently supported./ }, + { message: /Event pages are not currently supported./ }, + { + message: /Reading manifest: Warning processing background.nonExistentProp: An unexpected property was found/, + }, + ], + }, + true + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js new file mode 100644 index 0000000000..1393888eca --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_experiments.js @@ -0,0 +1,358 @@ +"use strict"; + +/* globals browser */ +const { AddonSettings } = ChromeUtils.import( + "resource://gre/modules/addons/AddonSettings.jsm" +); + +AddonTestUtils.init(this); + +add_task(async function setup() { + AddonTestUtils.overrideCertDB(); + await ExtensionTestUtils.startAddonManager(); +}); + +let fooExperimentAPIs = { + foo: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments", "foo", "parent"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["experiments", "foo", "child"]], + }, + }, +}; + +let fooExperimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "experiments.foo", + types: [ + { + id: "Meh", + type: "object", + properties: {}, + }, + ], + functions: [ + { + name: "parent", + type: "function", + async: true, + parameters: [], + }, + { + name: "child", + type: "function", + parameters: [], + returns: { type: "string" }, + }, + ], + }, + ]), + + /* globals ExtensionAPI */ + "parent.js": () => { + this.foo = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + foo: { + parent() { + return Promise.resolve("parent"); + }, + }, + }, + }; + } + }; + }, + + "child.js": () => { + this.foo = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + foo: { + child() { + return "child"; + }, + }, + }, + }; + } + }; + }, +}; + +async function testFooExperiment() { + browser.test.assertEq( + "object", + typeof browser.experiments, + "typeof browser.experiments" + ); + + browser.test.assertEq( + "object", + typeof browser.experiments.foo, + "typeof browser.experiments.foo" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.foo.child, + "typeof browser.experiments.foo.child" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.foo.parent, + "typeof browser.experiments.foo.parent" + ); + + browser.test.assertEq( + "child", + browser.experiments.foo.child(), + "foo.child()" + ); + + browser.test.assertEq( + "parent", + await browser.experiments.foo.parent(), + "await foo.parent()" + ); +} + +async function testFooFailExperiment() { + browser.test.assertEq( + "object", + typeof browser.experiments, + "typeof browser.experiments" + ); + + browser.test.assertEq( + "undefined", + typeof browser.experiments.foo, + "typeof browser.experiments.foo" + ); +} + +add_task(async function test_bundled_experiments() { + let testCases = [ + { isSystem: true, temporarilyInstalled: true, shouldHaveExperiments: true }, + { + isSystem: true, + temporarilyInstalled: false, + shouldHaveExperiments: true, + }, + { + isPrivileged: true, + temporarilyInstalled: true, + shouldHaveExperiments: true, + }, + { + isPrivileged: true, + temporarilyInstalled: false, + shouldHaveExperiments: true, + }, + { + isPrivileged: false, + temporarilyInstalled: true, + shouldHaveExperiments: AddonSettings.EXPERIMENTS_ENABLED, + }, + { + isPrivileged: false, + temporarilyInstalled: false, + shouldHaveExperiments: AppConstants.MOZ_APP_NAME == "thunderbird", + }, + ]; + + async function background(shouldHaveExperiments) { + if (shouldHaveExperiments) { + await testFooExperiment(); + } else { + await testFooFailExperiment(); + } + + browser.test.notifyPass("background.experiments.foo"); + } + + for (let testCase of testCases) { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: testCase.isPrivileged, + isSystem: testCase.isSystem, + temporarilyInstalled: testCase.temporarilyInstalled, + + manifest: { + experiment_apis: fooExperimentAPIs, + }, + + background: ` + ${testFooExperiment} + ${testFooFailExperiment} + (${background})(${testCase.shouldHaveExperiments}); + `, + + files: fooExperimentFiles, + }); + + await extension.startup(); + + await extension.awaitFinish("background.experiments.foo"); + + await extension.unload(); + } +}); + +add_task(async function test_unbundled_experiments() { + async function background() { + await testFooExperiment(); + + browser.test.assertEq( + "object", + typeof browser.experiments.crunk, + "typeof browser.experiments.crunk" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.crunk.child, + "typeof browser.experiments.crunk.child" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.crunk.parent, + "typeof browser.experiments.crunk.parent" + ); + + browser.test.assertEq( + "crunk-child", + browser.experiments.crunk.child(), + "crunk.child()" + ); + + browser.test.assertEq( + "crunk-parent", + await browser.experiments.crunk.parent(), + "await crunk.parent()" + ); + + browser.test.notifyPass("background.experiments.crunk"); + } + + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + + manifest: { + experiment_apis: fooExperimentAPIs, + + permissions: ["experiments.crunk"], + }, + + background: ` + ${testFooExperiment} + (${background})(); + `, + + files: fooExperimentFiles, + }); + + let apiExtension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + + manifest: { + applications: { gecko: { id: "crunk@experiments.addons.mozilla.org" } }, + + experiment_apis: { + crunk: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments", "crunk", "parent"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["experiments", "crunk", "child"]], + }, + }, + }, + }, + + files: { + "schema.json": JSON.stringify([ + { + namespace: "experiments.crunk", + types: [ + { + id: "Meh", + type: "object", + properties: {}, + }, + ], + functions: [ + { + name: "parent", + type: "function", + async: true, + parameters: [], + }, + { + name: "child", + type: "function", + parameters: [], + returns: { type: "string" }, + }, + ], + }, + ]), + + "parent.js": () => { + this.crunk = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + crunk: { + parent() { + return Promise.resolve("crunk-parent"); + }, + }, + }, + }; + } + }; + }, + + "child.js": () => { + this.crunk = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + crunk: { + child() { + return "crunk-child"; + }, + }, + }, + }; + } + }; + }, + }, + }); + + await apiExtension.startup(); + await extension.startup(); + + await extension.awaitFinish("background.experiments.crunk"); + + await extension.unload(); + await apiExtension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js new file mode 100644 index 0000000000..72fa161965 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_is_allowed_incognito_access() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + async function background() { + let allowed = await browser.extension.isAllowedIncognitoAccess(); + + browser.test.assertEq(true, allowed, "isAllowedIncognitoAccess is true"); + browser.test.notifyPass("isAllowedIncognitoAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitFinish("isAllowedIncognitoAccess"); + await extension.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); + +add_task(async function test_is_denied_incognito_access() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + async function background() { + let allowed = await browser.extension.isAllowedIncognitoAccess(); + + browser.test.assertEq(false, allowed, "isAllowedIncognitoAccess is false"); + browser.test.notifyPass("isNotAllowedIncognitoAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("isNotAllowedIncognitoAccess"); + await extension.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); + +add_task(async function test_in_incognito_context_false() { + function background() { + browser.test.assertEq( + false, + browser.extension.inIncognitoContext, + "inIncognitoContext returned false" + ); + browser.test.notifyPass("inIncognitoContext"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("inIncognitoContext"); + await extension.unload(); +}); + +add_task(async function test_is_allowed_file_scheme_access() { + async function background() { + let allowed = await browser.extension.isAllowedFileSchemeAccess(); + + browser.test.assertEq(false, allowed, "isAllowedFileSchemeAccess is false"); + browser.test.notifyPass("isAllowedFileSchemeAccess"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("isAllowedFileSchemeAccess"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js new file mode 100644 index 0000000000..19e046e12d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js @@ -0,0 +1,887 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ExtensionSettingsStore", + "resource://gre/modules/ExtensionSettingsStore.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); +var { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +let lastSetPref; + +const STORE_TYPE = "prefs"; + +// Test settings to use with the preferences manager. +const SETTINGS = { + multiple_prefs: { + prefNames: ["my.pref.1", "my.pref.2", "my.pref.3"], + + initalValues: ["value1", "value2", "value3"], + + valueFn(pref, value) { + return `${pref}-${value}`; + }, + + setCallback(value) { + let prefs = {}; + for (let pref of this.prefNames) { + prefs[pref] = this.valueFn(pref, value); + } + return prefs; + }, + }, + + singlePref: { + prefNames: ["my.single.pref"], + + initalValues: ["value1"], + + onPrefsChanged(item) { + lastSetPref = item; + }, + + valueFn(pref, value) { + return value; + }, + + setCallback(value) { + return { [this.prefNames[0]]: this.valueFn(null, value) }; + }, + }, +}; + +ExtensionPreferencesManager.addSetting( + "multiple_prefs", + SETTINGS.multiple_prefs +); +ExtensionPreferencesManager.addSetting("singlePref", SETTINGS.singlePref); + +// Set initial values for prefs. +for (let setting in SETTINGS) { + setting = SETTINGS[setting]; + for (let i = 0; i < setting.prefNames.length; i++) { + Preferences.set(setting.prefNames[i], setting.initalValues[i]); + } +} + +function checkPrefs(settingObj, value, msg) { + for (let pref of settingObj.prefNames) { + equal(Preferences.get(pref), settingObj.valueFn(pref, value), msg); + } +} + +function checkOnPrefsChanged(setting, value, msg) { + if (value) { + deepEqual(lastSetPref, value, msg); + lastSetPref = null; + } else { + ok(!lastSetPref, msg); + } +} + +add_task(async function test_preference_manager() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let extensions = testExtensions.map(extension => extension.extension); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + let newValue1 = "newValue1"; + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged has not been called yet" + ); + } + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl with no settings set." + ); + + let prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[1].id, + setting, + newValue1 + ); + ok(prefsChanged, "setSetting returns true when the pref(s) have been set."); + checkPrefs( + settingObj, + newValue1, + "setSetting sets the prefs for the first extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[1].id, value: newValue1, key: setting }, + "onPrefsChanged is called when pref changes" + ); + } + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl when a pref has been set." + ); + + let checkSetting = await ExtensionPreferencesManager.getSetting(setting); + equal( + checkSetting.value, + newValue1, + "getSetting returns the expected value." + ); + + let newValue2 = "newValue2"; + prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue2 + ); + ok( + !prefsChanged, + "setSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "setSetting does not set the pref(s) for an earlier extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change" + ); + } + + prefsChanged = await ExtensionPreferencesManager.disableSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "disableSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "disableSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on disable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.enableSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "enableSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "enableSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on enable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[0].id, + setting + ); + ok( + !prefsChanged, + "removeSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "removeSetting does not change the pref(s) for the non-top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change on remove" + ); + } + + prefsChanged = await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue2 + ); + ok( + !prefsChanged, + "setSetting returns false when the pref(s) have not been set." + ); + checkPrefs( + settingObj, + newValue1, + "setSetting does not set the pref(s) for an earlier extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + null, + "onPrefsChanged isn't called without control change again" + ); + } + + prefsChanged = await ExtensionPreferencesManager.disableSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "disableSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue2, + "disableSetting sets the pref(s) to the next value when disabling the top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[0].id, key: setting, value: newValue2 }, + "onPrefsChanged is called when control changes on disable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.enableSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "enableSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue1, + "enableSetting sets the pref(s) to the previous value(s)." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[1].id, key: setting, value: newValue1 }, + "onPrefsChanged is called when control changes on enable" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[1].id, + setting + ); + ok( + prefsChanged, + "removeSetting returns true when the pref(s) have been set." + ); + checkPrefs( + settingObj, + newValue2, + "removeSetting sets the pref(s) to the next value when removing the top extension." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { id: extensions[0].id, key: setting, value: newValue2 }, + "onPrefsChanged is called when control changes on remove" + ); + } + + prefsChanged = await ExtensionPreferencesManager.removeSetting( + extensions[0].id, + setting + ); + ok( + prefsChanged, + "removeSetting returns true when the pref(s) have been set." + ); + if (settingObj.onPrefsChanged) { + checkOnPrefsChanged( + setting, + { key: setting, initialValue: { "my.single.pref": "value1" } }, + "onPrefsChanged is called when control is entirely removed" + ); + } + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "removeSetting sets the pref(s) to the initial value(s) when removing the last extension." + ); + } + + checkSetting = await ExtensionPreferencesManager.getSetting(setting); + equal( + checkSetting, + null, + "getSetting returns null when nothing has been set." + ); + } + + // Tests for unsetAll. + let newValue3 = "newValue3"; + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + await ExtensionPreferencesManager.setSetting( + extensions[0].id, + setting, + newValue3 + ); + checkPrefs(settingObj, newValue3, "setSetting set the pref."); + } + + let setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual( + setSettings, + Object.keys(SETTINGS), + "Expected settings were set for extension." + ); + await ExtensionPreferencesManager.disableAll(extensions[0].id); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "disableAll unset the pref." + ); + } + } + + setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual( + setSettings, + Object.keys(SETTINGS), + "disableAll retains the settings." + ); + + await ExtensionPreferencesManager.enableAll(extensions[0].id); + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + checkPrefs(settingObj, newValue3, "enableAll re-set the pref."); + } + + await ExtensionPreferencesManager.removeAll(extensions[0].id); + + for (let setting in SETTINGS) { + let settingObj = SETTINGS[setting]; + for (let i = 0; i < settingObj.prefNames.length; i++) { + equal( + Preferences.get(settingObj.prefNames[i]), + settingObj.initalValues[i], + "removeAll unset the pref." + ); + } + } + + setSettings = await ExtensionSettingsStore.getAllForExtension( + extensions[0].id, + STORE_TYPE + ); + deepEqual(setSettings, [], "removeAll removed all settings."); + + // Tests for preventing automatic changes to manually edited prefs. + for (let setting in SETTINGS) { + let apiValue = "newValue"; + let manualValue = "something different"; + let settingObj = SETTINGS[setting]; + let extension = extensions[1]; + await ExtensionPreferencesManager.setSetting( + extension.id, + setting, + apiValue + ); + + let checkResetPrefs = method => { + let prefNames = settingObj.prefNames; + for (let i = 0; i < prefNames.length; i++) { + if (i === 0) { + equal( + Preferences.get(prefNames[0]), + manualValue, + `${method} did not change a manually set pref.` + ); + } else { + equal( + Preferences.get(prefNames[i]), + settingObj.valueFn(prefNames[i], apiValue), + `${method} did not change another pref when a pref was manually set.` + ); + } + } + }; + + // Manually set the preference to a different value. + Preferences.set(settingObj.prefNames[0], manualValue); + + await ExtensionPreferencesManager.disableAll(extension.id); + checkResetPrefs("disableAll"); + + await ExtensionPreferencesManager.enableAll(extension.id); + checkResetPrefs("enableAll"); + + await ExtensionPreferencesManager.removeAll(extension.id); + checkResetPrefs("removeAll"); + } + + // Test with an uninitialized pref. + let setting = "singlePref"; + let settingObj = SETTINGS[setting]; + let pref = settingObj.prefNames[0]; + let newValue = "newValue"; + Preferences.reset(pref); + await ExtensionPreferencesManager.setSetting( + extensions[1].id, + setting, + newValue + ); + equal( + Preferences.get(pref), + settingObj.valueFn(pref, newValue), + "Uninitialized pref is set." + ); + await ExtensionPreferencesManager.removeSetting(extensions[1].id, setting); + ok(!Preferences.has(pref), "removeSetting removed the pref."); + + // Test levelOfControl with a locked pref. + setting = "multiple_prefs"; + let prefToLock = SETTINGS[setting].prefNames[0]; + Preferences.lock(prefToLock, 1); + ok(Preferences.locked(prefToLock), `Preference ${prefToLock} is locked.`); + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + extensions[1].id, + setting + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when a pref is locked." + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_preference_manager_set_when_disabled() { + await promiseStartupManager(); + + let id = "@set-disabled-pref"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + + // We test both a default pref and a user-set pref. Get the default + // value off the pref we'll use. We fake the default pref by setting + // a value on it before creating the setting. + Services.prefs.setBoolPref("bar", true); + + function isUndefinedPref(pref) { + try { + Services.prefs.getStringPref(pref); + return false; + } catch (e) { + return true; + } + } + ok(isUndefinedPref("foo"), "test pref is not set"); + + await ExtensionSettingsStore.initialize(); + let lastItemChange = PromiseUtils.defer(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["foo", "bar"], + onPrefsChanged(item) { + lastItemChange.resolve(item); + lastItemChange = PromiseUtils.defer(); + }, + setCallback(value) { + return { [this.prefNames[0]]: value, [this.prefNames[1]]: false }; + }, + }); + + await ExtensionPreferencesManager.setSetting(id, "some-pref", "my value"); + + let item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "my value", "The value has been set"); + equal( + Services.prefs.getStringPref("foo"), + "my value", + "The user pref has been set" + ); + equal( + Services.prefs.getBoolPref("bar"), + false, + "The default pref has been set" + ); + + await ExtensionPreferencesManager.disableSetting(id, "some-pref"); + + // test that a disabled setting has been returned to the default value. In this + // case the pref is not a default pref, so it will be undefined. + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, undefined, "The value is back to default"); + equal(item.initialValue.foo, undefined, "The initialValue is correct"); + ok(isUndefinedPref("foo"), "user pref is not set"); + equal( + Services.prefs.getBoolPref("bar"), + true, + "The default pref has been restored to the default" + ); + + // test that setSetting() will enable a disabled setting + await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value"); + + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is set again"); + equal( + Services.prefs.getStringPref("foo"), + "new value", + "The user pref is set again" + ); + equal( + Services.prefs.getBoolPref("bar"), + false, + "The default pref has been set again" + ); + + // Force settings to be serialized and reloaded to mimick what happens + // with settings through a restart of Firefox. Bug 1576266. + await ExtensionSettingsStore._reloadFile(true); + + // Now unload the extension to test prefs are reset properly. + let promise = lastItemChange.promise; + await extension.unload(); + + // Test that the pref is unset when an extension is uninstalled. + item = await promise; + deepEqual( + item, + { key: "some-pref", initialValue: { bar: true } }, + "The value has been reset" + ); + ok(isUndefinedPref("foo"), "user pref is not set"); + equal( + Services.prefs.getBoolPref("bar"), + true, + "The default pref has been restored to the default" + ); + Services.prefs.clearUserPref("bar"); + + await promiseShutdownManager(); +}); + +add_task(async function test_preference_default_upgraded() { + await promiseStartupManager(); + + let id = "@upgrade-pref"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + + // We set the default value for a pref here so it will be + // picked up by EPM. + let defaultPrefs = Services.prefs.getDefaultBranch(null); + defaultPrefs.setStringPref("bar", "initial default"); + + await ExtensionSettingsStore.initialize(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["bar"], + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + }); + + await ExtensionPreferencesManager.setSetting(id, "some-pref", "new value"); + let item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is set"); + + defaultPrefs.setStringPref("bar", "new default"); + + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "new value", "The value is still set"); + + let prefsChanged = await ExtensionPreferencesManager.removeSetting( + id, + "some-pref" + ); + ok(prefsChanged, "pref changed on removal of setting."); + equal(Preferences.get("bar"), "new default", "default value is correct"); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_preference_select() { + await promiseStartupManager(); + + let extensionData = { + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@one" } }, + }, + }; + let one = ExtensionTestUtils.loadExtension(extensionData); + + await one.startup(); + + // We set the default value for a pref here so it will be + // picked up by EPM. + let defaultPrefs = Services.prefs.getDefaultBranch(null); + defaultPrefs.setStringPref("bar", "initial default"); + + await ExtensionSettingsStore.initialize(); + ExtensionPreferencesManager.addSetting("some-pref", { + prefNames: ["bar"], + setCallback(value) { + return { [this.prefNames[0]]: value }; + }, + }); + + ok( + await ExtensionPreferencesManager.setSetting( + one.id, + "some-pref", + "new value" + ), + "setting was changed" + ); + let item = await ExtensionPreferencesManager.getSetting("some-pref"); + equal(item.value, "new value", "The value is set"); + + // User-set the setting. + await ExtensionPreferencesManager.selectSetting(null, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + // Extensions installed before cannot gain control again. + let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + one.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set." + ); + + // Enabling the top-precedence addon does not take over a user-set setting. + await ExtensionPreferencesManager.disableSetting(one.id, "some-pref"); + await ExtensionPreferencesManager.enableSetting(one.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + // Upgrading does not override the user-set setting. + extensionData.manifest.version = "2.0"; + extensionData.manifest.incognito = "not_allowed"; + await one.upgrade(extensionData); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + one.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set after addon upgrade." + ); + + // We can re-select the extension. + await ExtensionPreferencesManager.selectSetting(one.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual(item.value, "new value", "The value is extension set"); + + // An extension installed after user-set can take over the setting. + await ExtensionPreferencesManager.selectSetting(null, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + deepEqual( + item, + { key: "some-pref", initialValue: {} }, + "The value is user-set" + ); + + let two = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@two" } }, + }, + }); + + await two.startup(); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + two.id, + "some-pref" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + await ExtensionPreferencesManager.setSetting( + two.id, + "some-pref", + "another value" + ); + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "another value", "The value is set"); + + // A new installed extension can override a user selected extension. + let three = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@three" } }, + }, + }); + + // user selects specific extension to take control + await ExtensionPreferencesManager.selectSetting(one.id, "some-pref"); + + // two cannot control + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + two.id, + "some-pref" + ); + equal( + levelOfControl, + "not_controllable", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + // three can control after install + await three.startup(); + levelOfControl = await ExtensionPreferencesManager.getLevelOfControl( + three.id, + "some-pref" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl when user-set after addon install." + ); + + await ExtensionPreferencesManager.setSetting( + three.id, + "some-pref", + "third value" + ); + item = ExtensionSettingsStore.getSetting("prefs", "some-pref"); + equal(item.value, "third value", "The value is set"); + + // We have returned to precedence based settings. + await ExtensionPreferencesManager.removeSetting(three.id, "some-pref"); + await ExtensionPreferencesManager.removeSetting(two.id, "some-pref"); + item = await ExtensionPreferencesManager.getSetting("some-pref"); + equal(item.value, "new value", "The value is extension set"); + + await one.unload(); + await two.unload(); + await three.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_preference_select() { + let prefNames = await ExtensionPreferencesManager.getManagedPrefDetails(); + // Just check a subset of settings that are in this test file. + Assert.ok(prefNames.size > 0, "some prefs exist"); + for (let settingName in SETTINGS) { + let setting = SETTINGS[settingName]; + for (let prefName of setting.prefNames) { + Assert.equal( + prefNames.get(prefName), + settingName, + "setting retrieved prefNames" + ); + } + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js new file mode 100644 index 0000000000..e4baa79a2c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionSettingsStore.js @@ -0,0 +1,1089 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionSettingsStore", + "resource://gre/modules/ExtensionSettingsStore.jsm" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +const ITEMS = { + key1: [ + { key: "key1", value: "val1", id: "@first" }, + { key: "key1", value: "val2", id: "@second" }, + { key: "key1", value: "val3", id: "@third" }, + ], + key2: [ + { key: "key2", value: "val1-2", id: "@first" }, + { key: "key2", value: "val2-2", id: "@second" }, + { key: "key2", value: "val3-2", id: "@third" }, + ], +}; +const KEY_LIST = Object.keys(ITEMS); +const TEST_TYPE = "myType"; + +let callbackCount = 0; + +function initialValue(key) { + callbackCount++; + return `key:${key}`; +} + +add_task(async function test_settings_store() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@second" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@third" } }, + }, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let extensions = testExtensions.map(extension => extension.extension); + + let expectedCallbackCount = 0; + + await Assert.rejects( + ExtensionSettingsStore.getLevelOfControl(1, TEST_TYPE, "key"), + /The ExtensionSettingsStore was accessed before the initialize promise resolved/, + "Accessing the SettingsStore before it is initialized throws an error." + ); + + // Initialize the SettingsStore. + await ExtensionSettingsStore.initialize(); + + // Add a setting for the second oldest extension, where it is the only setting for a key. + for (let key of KEY_LIST) { + let extensionIndex = 1; + let itemToAdd = ITEMS[key][extensionIndex]; + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl with no settings set for a key." + ); + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + expectedCallbackCount++; + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Adding initial item for a key returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item with only one item in the list." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl with only one item in the list." + ); + ok( + ExtensionSettingsStore.hasSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ), + "hasSetting returns the correct value when an extension has a setting set." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with only one item in the list." + ); + } + + // Add a setting for the oldest extension. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let itemToAdd = ITEMS[key][extensionIndex]; + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + equal( + item, + null, + "An older extension adding a setting for a key returns null" + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item with more than one item in the list." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl when another extension is in control." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with more than one item in the list." + ); + } + + // Reload the settings store to emulate a browser restart. + await ExtensionSettingsStore._reloadFile(); + + // Add a setting for the newest extension. + for (let key of KEY_LIST) { + let extensionIndex = 2; + let itemToAdd = ITEMS[key][extensionIndex]; + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl for a more recent extension." + ); + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Adding item for most recent extension returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item with more than one item in the list." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl when this extension is in control." + ); + item = await ExtensionSettingsStore.getSetting( + TEST_TYPE, + key, + extensions[extensionIndex].id + ); + deepEqual( + item, + itemToAdd, + "getSetting with id returns correct item with more than one item in the list." + ); + } + + for (let extension of extensions) { + let items = await ExtensionSettingsStore.getAllForExtension( + extension.id, + TEST_TYPE + ); + deepEqual(items, KEY_LIST, "getAllForExtension returns expected keys."); + } + + // Attempting to remove a setting that has not been set should *not* throw an exception. + let removeResult = await ExtensionSettingsStore.removeSetting( + extensions[0].id, + "myType", + "unset_key" + ); + equal( + removeResult, + null, + "Removing a setting that was not previously set returns null." + ); + + // Attempting to disable a setting that has not been set should throw an exception. + Assert.throws( + () => + ExtensionSettingsStore.disable(extensions[0].id, "myType", "unset_key"), + /Cannot alter the setting for myType:unset_key as it does not exist/, + "disable rejects with an unset key." + ); + + // Attempting to enable a setting that has not been set should throw an exception. + Assert.throws( + () => + ExtensionSettingsStore.enable(extensions[0].id, "myType", "unset_key"), + /Cannot alter the setting for myType:unset_key as it does not exist/, + "enable rejects with an unset key." + ); + + let expectedKeys = KEY_LIST; + // Disable the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key, + "new value", + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + equal(item, null, "Updating non-top item for a key returns null"); + item = await ExtensionSettingsStore.disable( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Disabling non-top item for a key returns null."); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after a disable." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after a disable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after disabling of non-top item." + ); + } + + // Re-enable the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.enable( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Enabling non-top item for a key returns null."); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after an enable." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after an enable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after enabling of non-top item." + ); + } + + // Remove the non-top item for a key. + for (let key of KEY_LIST) { + let extensionIndex = 0; + let item = await ExtensionSettingsStore.removeSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal(item, null, "Removing non-top item for a key returns null."); + expectedKeys = expectedKeys.filter(expectedKey => expectedKey != key); + let allForExtension = await ExtensionSettingsStore.getAllForExtension( + extensions[extensionIndex].id, + TEST_TYPE + ); + deepEqual( + allForExtension, + expectedKeys, + "getAllForExtension returns expected keys after a removal." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after a removal." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[extensionIndex].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_other_extensions", + "getLevelOfControl returns correct levelOfControl after removal of non-top item." + ); + ok( + !ExtensionSettingsStore.hasSetting( + extensions[extensionIndex].id, + TEST_TYPE, + key + ), + "hasSetting returns the correct value when an extension does not have a setting set." + ); + } + + for (let key of KEY_LIST) { + // Disable the top item for a key. + let item = await ExtensionSettingsStore.disable( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][1], + "Disabling top item for a key returns the new top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item after a disable." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after disabling of top item." + ); + + // Re-enable the top item for a key. + item = await ExtensionSettingsStore.enable( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][2], + "Re-enabling top item for a key returns the old top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][2], + "getSetting returns correct item after an enable." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after re-enabling top item." + ); + + // Remove the top item for a key. + item = await ExtensionSettingsStore.removeSetting( + extensions[2].id, + TEST_TYPE, + key + ); + deepEqual( + item, + ITEMS[key][1], + "Removing top item for a key returns the new top item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + ITEMS[key][1], + "getSetting returns correct item after a removal." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[2].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after removal of top item." + ); + + // Add a setting for the current top item. + let itemToAdd = { key, value: `new-${key}`, id: "@second" }; + item = await ExtensionSettingsStore.addSetting( + extensions[1].id, + TEST_TYPE, + itemToAdd.key, + itemToAdd.value, + initialValue + ); + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + deepEqual( + item, + itemToAdd, + "Updating top item for a key returns that item." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns correct item after updating." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after updating." + ); + + // Disable the last remaining item for a key. + let expectedItem = { key, initialValue: initialValue(key) }; + // We're using the callback to set the expected value, so we need to increment the + // expectedCallbackCount. + expectedCallbackCount++; + item = await ExtensionSettingsStore.disable( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + expectedItem, + "Disabling last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + expectedItem, + "getSetting returns the initial value after all are disabled." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after all are disabled." + ); + + // Re-enable the last remaining item for a key. + item = await ExtensionSettingsStore.enable( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + itemToAdd, + "Re-enabling last item for a key returns the old value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual( + item, + itemToAdd, + "getSetting returns expected value after re-enabling." + ); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controlled_by_this_extension", + "getLevelOfControl returns correct levelOfControl after re-enabling." + ); + + // Remove the last remaining item for a key. + item = await ExtensionSettingsStore.removeSetting( + extensions[1].id, + TEST_TYPE, + key + ); + deepEqual( + item, + expectedItem, + "Removing last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, key); + deepEqual(item, null, "getSetting returns null after all are removed."); + levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl after all are removed." + ); + + // Attempting to remove a setting that has had all extensions removed should *not* throw an exception. + removeResult = await ExtensionSettingsStore.removeSetting( + extensions[1].id, + TEST_TYPE, + key + ); + equal( + removeResult, + null, + "Removing a setting that has had all extensions removed returns null." + ); + } + + // Test adding a setting with a value in callbackArgument. + let extensionIndex = 0; + let testKey = "callbackArgumentKey"; + let callbackArgumentValue = Date.now(); + // Add the setting. + let item = await ExtensionSettingsStore.addSetting( + extensions[extensionIndex].id, + TEST_TYPE, + testKey, + 1, + initialValue, + callbackArgumentValue + ); + expectedCallbackCount++; + equal( + callbackCount, + expectedCallbackCount, + "initialValueCallback called the expected number of times." + ); + // Remove the setting which should return the initial value. + let expectedItem = { + key: testKey, + initialValue: initialValue(callbackArgumentValue), + }; + // We're using the callback to set the expected value, so we need to increment the + // expectedCallbackCount. + expectedCallbackCount++; + item = await ExtensionSettingsStore.removeSetting( + extensions[extensionIndex].id, + TEST_TYPE, + testKey + ); + deepEqual( + item, + expectedItem, + "Removing last item for a key returns the initial value." + ); + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, testKey); + deepEqual(item, null, "getSetting returns null after all are removed."); + + item = await ExtensionSettingsStore.getSetting(TEST_TYPE, "not a key"); + equal( + item, + null, + "getSetting returns a null item if the setting does not have any records." + ); + let levelOfControl = await ExtensionSettingsStore.getLevelOfControl( + extensions[1].id, + TEST_TYPE, + "not a key" + ); + equal( + levelOfControl, + "controllable_by_this_extension", + "getLevelOfControl returns correct levelOfControl if the setting does not have any records." + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_store_setByUser() { + await promiseStartupManager(); + + // Create an array of test framework extension wrappers to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@second" } }, + }, + }), + ]; + + let type = "some_type"; + let key = "some_key"; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Create an array actual Extension objects which correspond to the + // test framework extension wrappers. + let [one, two] = testExtensions.map(extension => extension.extension); + let initialCallback = () => "initial"; + + // Initialize the SettingsStore. + await ExtensionSettingsStore.initialize(); + + equal( + null, + ExtensionSettingsStore.getSetting(type, key), + "getSetting is initially null" + ); + + let item = await ExtensionSettingsStore.addSetting( + one.id, + type, + key, + "one", + initialCallback + ); + deepEqual( + { key, value: "one", id: one.id }, + item, + "addSetting returns the first set item" + ); + + item = await ExtensionSettingsStore.addSetting( + two.id, + type, + key, + "two", + initialCallback + ); + deepEqual( + { key, value: "two", id: two.id }, + item, + "addSetting returns the second set item" + ); + + // a user-set selection reverts to precedence order when new + // extension sets the setting. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + let three = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@third" } }, + }, + }); + await three.startup(); + + item = await ExtensionSettingsStore.addSetting( + three.id, + type, + key, + "three", + initialCallback + ); + deepEqual( + { key, value: "three", id: three.id }, + item, + "addSetting returns the third set item" + ); + deepEqual( + item, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the third set item" + ); + + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + item = ExtensionSettingsStore.select(one.id, type, key); + deepEqual( + { key, value: "one", id: one.id }, + item, + "selecting an extension returns the first set item after enable" + ); + + // Disabling a selected item returns to precedence order + ExtensionSettingsStore.disable(one.id, type, key); + deepEqual( + { key, value: "three", id: three.id }, + ExtensionSettingsStore.getSetting(type, key), + "returning to precedence order sets the third set item" + ); + + // Test that disabling all then enabling one does not take over a user-set setting. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + type, + key + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after being set by user" + ); + + ExtensionSettingsStore.disable(three.id, type, key); + ExtensionSettingsStore.disable(two.id, type, key); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after disabling all extensions" + ); + + ExtensionSettingsStore.enable(three.id, type, key); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after enabling one extension" + ); + + // Ensure that calling addSetting again will not reset a user-set value when + // the extension install date is older than the user-set date. + item = await ExtensionSettingsStore.addSetting( + three.id, + type, + key, + "three", + initialCallback + ); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after calling addSetting for old addon" + ); + + item = ExtensionSettingsStore.enable(three.id, type, key); + equal(undefined, item, "enabling the active item does not return an item"); + deepEqual( + { key, initialValue: "initial" }, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns the initial value after enabling one extension" + ); + + ExtensionSettingsStore.removeSetting(three.id, type, key); + ExtensionSettingsStore.removeSetting(two.id, type, key); + ExtensionSettingsStore.removeSetting(one.id, type, key); + + equal( + null, + ExtensionSettingsStore.getSetting(type, key), + "getSetting returns null after removing all settings" + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_store_add_disabled() { + await promiseStartupManager(); + + let id = "@add-on-disable"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + await ExtensionSettingsStore.initialize(); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + ExtensionSettingsStore.disable(id, "foo", "bar"); + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, undefined, "The add-on is not in control"); + equal(item.initialValue, "not set", "The value is not set"); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_settings_uninstall_remove() { + await promiseStartupManager(); + + let id = "@add-on-uninstall"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + await ExtensionSettingsStore.initialize(); + + await ExtensionSettingsStore.addSetting( + id, + "foo", + "bar", + "set", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, id, "The add-on is in control"); + equal(item.value, "set", "The value is set"); + + await extension.unload(); + + await promiseShutdownManager(); + + item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item, null, "The add-on setting was removed"); +}); + +add_task(async function test_exceptions() { + await ExtensionSettingsStore.initialize(); + + await Assert.rejects( + ExtensionSettingsStore.addSetting( + 1, + TEST_TYPE, + "key_not_a_function", + "val1", + "not a function" + ), + /initialValueCallback must be a function/, + "addSetting rejects with a callback that is not a function." + ); +}); + +add_task(async function test_get_all_settings() { + await promiseStartupManager(); + + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@first" } }, + }, + }), + ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + applications: { gecko: { id: "@second" } }, + }, + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + await ExtensionSettingsStore.initialize(); + + let items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 0, "There are no addons controlling this setting yet"); + + await ExtensionSettingsStore.addSetting( + "@first", + "foo", + "bar", + "set", + () => "not set" + ); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 1, "The add-on setting has 1 addon trying to control it"); + + await ExtensionSettingsStore.addSetting( + "@second", + "foo", + "bar", + "setting", + () => "not set" + ); + + let item = ExtensionSettingsStore.getSetting("foo", "bar"); + equal(item.id, "@second", "The second add-on is in control"); + equal(item.value, "setting", "The second value is set"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal( + items.length, + 2, + "The add-on setting has 2 addons trying to control it" + ); + + await ExtensionSettingsStore.removeSetting("@first", "foo", "bar"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal(items.length, 1, "There is only 1 addon controlling this setting"); + + await ExtensionSettingsStore.removeSetting("@second", "foo", "bar"); + + items = ExtensionSettingsStore.getAllSettings("foo", "bar"); + equal( + items.length, + 0, + "There is no longer any addon controlling this setting" + ); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js new file mode 100644 index 0000000000..8044a07c71 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_content_telemetry.js @@ -0,0 +1,151 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS"; +const HISTOGRAM_KEYED = "WEBEXT_CONTENT_SCRIPT_INJECTION_MS_BY_ADDONID"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_telemetry() { + function contentScript() { + browser.test.sendMessage("content-script-run"); + } + + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + let extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_end", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_end", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, + }); + + clearHistograms(); + + let process = IS_OOP ? "content" : "parent"; + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + + await extension1.startup(); + let extensionId = extension1.extension.id; + + info(`Started extension with id ${extensionId}`); + + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram after startup: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension1.awaitMessage("content-script-run"); + await promiseTelemetryRecorded(HISTOGRAM, process, 1); + await promiseKeyedTelemetryRecorded(HISTOGRAM_KEYED, process, extensionId, 1); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + + await contentPage.close(); + await extension1.unload(); + + await extension2.startup(); + let extensionId2 = extension2.extension.id; + + info(`Started extension with id ${extensionId2}`); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `No new data recorded for histogram after extension2 startup: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram after extension2 startup: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + ok( + !(extensionId2 in getKeyedSnapshots(process)[HISTOGRAM_KEYED]), + `No data recorded for histogram after startup: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + + contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension2.awaitMessage("content-script-run"); + await promiseTelemetryRecorded(HISTOGRAM, process, 2); + await promiseKeyedTelemetryRecorded( + HISTOGRAM_KEYED, + process, + extensionId2, + 1 + ); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 2, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId2].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + + await contentPage.close(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js new file mode 100644 index 0000000000..5e995b3aa6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_failure.js @@ -0,0 +1,46 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionTestCommon } = ChromeUtils.import( + "resource://testing-common/ExtensionTestCommon.jsm" +); + +add_task(async function extension_startup_early_error() { + const EXTENSION_ID = "@extension-with-package-error"; + let extension = ExtensionTestCommon.generate({ + manifest: { + applications: { gecko: { id: EXTENSION_ID } }, + }, + }); + + extension.initLocale = async function() { + // Simulate error that happens during startup. + extension.packagingError("dummy error"); + }; + + let startupPromise = extension.startup(); + + let policy = WebExtensionPolicy.getByID(EXTENSION_ID); + ok(policy, "WebExtensionPolicy instantiated at startup"); + let readyPromise = policy.readyPromise; + ok(readyPromise, "WebExtensionPolicy.readyPromise is set"); + + await Assert.rejects( + startupPromise, + /dummy error/, + "Extension with packaging error should fail to load" + ); + + Assert.equal( + WebExtensionPolicy.getByID(EXTENSION_ID), + null, + "WebExtensionPolicy should be unregistered" + ); + + Assert.equal( + await readyPromise, + null, + "policy.readyPromise should be resolved with null" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js new file mode 100644 index 0000000000..fe1fab4ea2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_extension_startup_telemetry.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_EXTENSION_STARTUP_MS"; +const HISTOGRAM_KEYED = "WEBEXT_EXTENSION_STARTUP_MS_BY_ADDONID"; + +function processSnapshot(snapshot) { + return snapshot.sum > 0; +} + +function processKeyedSnapshot(snapshot) { + let res = {}; + for (let key of Object.keys(snapshot)) { + res[key] = snapshot[key].sum > 0; + } + return res; +} + +add_task(async function test_telemetry() { + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + let extension1 = ExtensionTestUtils.loadExtension({}); + let extension2 = ExtensionTestUtils.loadExtension({}); + + clearHistograms(); + + assertHistogramEmpty(HISTOGRAM); + assertKeyedHistogramEmpty(HISTOGRAM_KEYED); + + await extension1.startup(); + + assertHistogramSnapshot( + HISTOGRAM, + { processSnapshot, expectedValue: true }, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + }, + }, + `Data recorded for first extension for histogram ${HISTOGRAM_KEYED}` + ); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = Services.telemetry.getKeyedHistogramById( + HISTOGRAM_KEYED + ); + let histogramSum = histogram.snapshot().sum; + let histogramSumExt1 = histogramKeyed.snapshot()[extension1.extension.id].sum; + + await extension2.startup(); + + assertHistogramSnapshot( + HISTOGRAM, + { + processSnapshot: snapshot => snapshot.sum > histogramSum, + expectedValue: true, + }, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + + assertHistogramSnapshot( + HISTOGRAM_KEYED, + { + keyed: true, + processSnapshot: processKeyedSnapshot, + expectedValue: { + [extension1.extension.id]: true, + [extension2.extension.id]: true, + }, + }, + `Data recorded for second extension for histogram ${HISTOGRAM_KEYED}` + ); + + equal( + histogramKeyed.snapshot()[extension1.extension.id].sum, + histogramSumExt1, + `Data recorder for first extension is unchanged on the keyed histogram ${HISTOGRAM_KEYED}` + ); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js new file mode 100644 index 0000000000..c05188cd38 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_file_access.js @@ -0,0 +1,193 @@ +"use strict"; + +const FILE_DUMMY_URL = Services.io.newFileURI( + do_get_file("data/dummy_page.html") +).spec; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +// XHR/fetch from content script to the page itself is allowed. +add_task(async function content_script_xhr_to_self() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["file:///*"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": async () => { + let response = await fetch(document.URL); + browser.test.assertEq(200, response.status, "expected load"); + let responseText = await response.text(); + browser.test.assertTrue( + responseText.includes("<p>Page</p>"), + `expected file content in response of ${response.url}` + ); + + // Now with content.fetch: + response = await content.fetch(document.URL); + browser.test.assertEq(200, response.status, "expected load (content)"); + + browser.test.sendMessage("done"); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL); + await extension.awaitMessage("done"); + await contentPage.close(); + + await extension.unload(); +}); + +// XHR/fetch for other file is not allowed, even with file://-permissions. +add_task(async function content_script_xhr_to_other_file_not_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["file:///*"], + content_scripts: [ + { + matches: ["file:///*"], + js: ["content_script.js"], + }, + ], + }, + files: { + "content_script.js": async () => { + let otherFileUrl = document.URL.replace( + "dummy_page.html", + "file_sample.html" + ); + let x = new XMLHttpRequest(); + x.open("GET", otherFileUrl); + await new Promise(resolve => { + x.onloadend = resolve; + x.send(); + }); + browser.test.assertEq(0, x.status, "expected error"); + browser.test.assertEq("", x.responseText, "request should fail"); + + // Now with content.XMLHttpRequest. + x = new content.XMLHttpRequest(); + x.open("GET", otherFileUrl); + x.onloadend = () => { + browser.test.assertEq(0, x.status, "expected error (content)"); + browser.test.sendMessage("done"); + }; + x.send(); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage(FILE_DUMMY_URL); + await extension.awaitMessage("done"); + await contentPage.close(); + + await extension.unload(); +}); + +// "file://" permission does not grant access to files in the extension page. +add_task(async function file_access_from_extension_page_not_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["file:///*"], + description: FILE_DUMMY_URL, + }, + async background() { + const FILE_DUMMY_URL = browser.runtime.getManifest().description; + + await browser.test.assertRejects( + fetch(FILE_DUMMY_URL), + /NetworkError when attempting to fetch resource/, + "block request to file from background page despite file permission" + ); + + // Regression test for bug 1420296 . + await browser.test.assertRejects( + fetch(FILE_DUMMY_URL, { mode: "same-origin" }), + /NetworkError when attempting to fetch resource/, + "block request to file from background page despite 'same-origin' mode" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("done"); + + await extension.unload(); +}); + +// webRequest listeners should see subresource requests from file:-principals. +add_task(async function webRequest_script_request_from_file_principals() { + // Extension without file:-permission should not see the request. + let extensionWithoutFilePermission = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.net/", "webRequest"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail(`Unexpected request from ${details.originUrl}`); + }, + { urls: ["http://example.net/intercept_by_webRequest.js"] } + ); + }, + }); + + // Extension with <all_urls> (which matches the resource URL at example.net + // and the origin at file://*/*) can see the request. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>", "webRequest", "webRequestBlocking"], + web_accessible_resources: ["testDONE.html"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + ({ originUrl }) => { + browser.test.assertTrue( + /^file:.*file_do_load_script_subresource.html/.test(originUrl), + `expected script to be loaded from a local file (${originUrl})` + ); + let redirectUrl = browser.runtime.getURL("testDONE.html"); + return { + redirectUrl: `data:text/javascript,location.href='${redirectUrl}';`, + }; + }, + { urls: ["http://example.net/intercept_by_webRequest.js"] }, + ["blocking"] + ); + }, + files: { + "testDONE.html": `<!DOCTYPE html><script src="testDONE.js"></script>`, + "testDONE.js"() { + browser.test.sendMessage("webRequest_redirect_completed"); + }, + }, + }); + + await extensionWithoutFilePermission.startup(); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + Services.io.newFileURI( + do_get_file("data/file_do_load_script_subresource.html") + ).spec + ); + await extension.awaitMessage("webRequest_redirect_completed"); + await contentPage.close(); + + await extension.unload(); + await extensionWithoutFilePermission.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js new file mode 100644 index 0000000000..69c24cfc4b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_control.js @@ -0,0 +1,208 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +let getExtension = () => { + return ExtensionTestUtils.loadExtension({ + background: async function() { + const runningListener = isRunning => { + if (isRunning) { + browser.test.sendMessage("started"); + } else { + browser.test.sendMessage("stopped"); + } + }; + + browser.test.onMessage.addListener(async (message, data) => { + let result; + switch (message) { + case "start": + result = await browser.geckoProfiler.start({ + bufferSize: 10000, + windowLength: 20, + interval: 0.5, + features: ["js"], + threads: ["GeckoMain"], + }); + browser.test.assertEq(undefined, result, "start returns nothing."); + break; + case "stop": + result = await browser.geckoProfiler.stop(); + browser.test.assertEq(undefined, result, "stop returns nothing."); + break; + case "pause": + result = await browser.geckoProfiler.pause(); + browser.test.assertEq(undefined, result, "pause returns nothing."); + browser.test.sendMessage("paused"); + break; + case "resume": + result = await browser.geckoProfiler.resume(); + browser.test.assertEq(undefined, result, "resume returns nothing."); + browser.test.sendMessage("resumed"); + break; + case "test profile": + result = await browser.geckoProfiler.getProfile(); + browser.test.assertTrue( + "libs" in result, + "The profile contains libs." + ); + browser.test.assertTrue( + "meta" in result, + "The profile contains meta." + ); + browser.test.assertTrue( + "threads" in result, + "The profile contains threads." + ); + browser.test.assertTrue( + result.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); + browser.test.sendMessage("tested profile"); + break; + case "test dump to file": + try { + await browser.geckoProfiler.dumpProfileToFile(data.fileName); + browser.test.sendMessage("tested dump to file", {}); + } catch (e) { + browser.test.sendMessage("tested dump to file", { + error: e.message, + }); + } + break; + case "test profile as array buffer": + let arrayBuffer = await browser.geckoProfiler.getProfileAsArrayBuffer(); + browser.test.assertTrue( + arrayBuffer.byteLength >= 2, + "The profile array buffer contains data." + ); + let textDecoder = new TextDecoder(); + let profile = JSON.parse(textDecoder.decode(arrayBuffer)); + browser.test.assertTrue( + "libs" in profile, + "The profile contains libs." + ); + browser.test.assertTrue( + "meta" in profile, + "The profile contains meta." + ); + browser.test.assertTrue( + "threads" in profile, + "The profile contains threads." + ); + browser.test.assertTrue( + profile.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); + browser.test.sendMessage("tested profile as array buffer"); + break; + case "remove runningListener": + browser.geckoProfiler.onRunning.removeListener(runningListener); + browser.test.sendMessage("removed runningListener"); + break; + } + }); + + browser.test.sendMessage("ready"); + + browser.geckoProfiler.onRunning.addListener(runningListener); + }, + + manifest: { + permissions: ["geckoProfiler"], + applications: { + gecko: { + id: "profilertest@mozilla.com", + }, + }, + }, + }); +}; + +let verifyProfileData = bytes => { + let textDecoder = new TextDecoder(); + let profile = JSON.parse(textDecoder.decode(bytes)); + ok("libs" in profile, "The profile contains libs."); + ok("meta" in profile, "The profile contains meta."); + ok("threads" in profile, "The profile contains threads."); + ok( + profile.threads.some(t => t.name == "GeckoMain"), + "The profile contains a GeckoMain thread." + ); +}; + +add_task(async function testProfilerControl() { + const acceptedExtensionIdsPref = + "extensions.geckoProfiler.acceptedExtensionIds"; + Services.prefs.setCharPref( + acceptedExtensionIdsPref, + "profilertest@mozilla.com" + ); + + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("ready"); + await extension.awaitMessage("stopped"); + + extension.sendMessage("start"); + await extension.awaitMessage("started"); + + extension.sendMessage("test profile"); + await extension.awaitMessage("tested profile"); + + const profilerPath = OS.Path.join(OS.Constants.Path.profileDir, "profiler"); + let data, fileName, targetPath; + + // test with file name only + fileName = "bar.profile"; + targetPath = OS.Path.join(profilerPath, fileName); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, undefined, "No error thrown"); + ok(await OS.File.exists(targetPath), "Saved gecko profile exists."); + verifyProfileData(await OS.File.read(targetPath)); + + // test overwriting the formerly created file + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, undefined, "No error thrown"); + ok(await OS.File.exists(targetPath), "Saved gecko profile exists."); + verifyProfileData(await OS.File.read(targetPath)); + + // test with a POSIX path, which is not allowed + fileName = "foo/bar.profile"; + targetPath = OS.Path.join(profilerPath, ...fileName.split("/")); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, "Path cannot contain a subdirectory."); + ok(!(await OS.File.exists(targetPath)), "Gecko profile hasn't been saved."); + + // test with a non POSIX path which is not allowed + fileName = "foo\\bar.profile"; + targetPath = OS.Path.join(profilerPath, ...fileName.split("\\")); + extension.sendMessage("test dump to file", { fileName }); + data = await extension.awaitMessage("tested dump to file"); + equal(data.error, "Path cannot contain a subdirectory."); + ok(!(await OS.File.exists(targetPath)), "Gecko profile hasn't been saved."); + + extension.sendMessage("test profile as array buffer"); + await extension.awaitMessage("tested profile as array buffer"); + + extension.sendMessage("pause"); + await extension.awaitMessage("paused"); + + extension.sendMessage("resume"); + await extension.awaitMessage("resumed"); + + extension.sendMessage("stop"); + await extension.awaitMessage("stopped"); + + extension.sendMessage("remove runningListener"); + await extension.awaitMessage("removed runningListener"); + + await extension.unload(); + + Services.prefs.clearUserPref(acceptedExtensionIdsPref); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js new file mode 100644 index 0000000000..d0bbf7e60f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geckoProfiler_schema.js @@ -0,0 +1,56 @@ +"use strict"; + +add_task(async function() { + const acceptedExtensionIdsPref = + "extensions.geckoProfiler.acceptedExtensionIds"; + Services.prefs.setCharPref( + acceptedExtensionIdsPref, + "profilertest@mozilla.com" + ); + + let extension = ExtensionTestUtils.loadExtension({ + background: () => { + browser.test.sendMessage( + "features", + Object.values(browser.geckoProfiler.ProfilerFeature) + ); + }, + manifest: { + permissions: ["geckoProfiler"], + applications: { + gecko: { + id: "profilertest@mozilla.com", + }, + }, + }, + }); + + await extension.startup(); + let acceptedFeatures = await extension.awaitMessage("features"); + await extension.unload(); + + Services.prefs.clearUserPref(acceptedExtensionIdsPref); + + const allFeaturesAcceptedByProfiler = Services.profiler.GetAllFeatures(); + ok( + allFeaturesAcceptedByProfiler.length >= 2, + "Either we've massively reduced the profiler's feature set, or something is wrong." + ); + + // Check that the list of available values in the ProfilerFeature enum + // matches the list of features supported by the profiler. + for (const feature of allFeaturesAcceptedByProfiler) { + ok( + acceptedFeatures.includes(feature), + `The schema of the geckoProfiler.start() method should accept the "${feature}" feature.` + ); + } + for (const feature of acceptedFeatures) { + ok( + // Bug 1594566 - ignore Responsiveness until the extension is updated + allFeaturesAcceptedByProfiler.includes(feature) || + feature == "responsiveness", + `The schema of the geckoProfiler.start() method mentions a "${feature}" feature which is not supported by the profiler.` + ); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js new file mode 100644 index 0000000000..63b1016293 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_geturl.js @@ -0,0 +1,61 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessage.addListener(([url1, url2]) => { + let url3 = browser.runtime.getURL("test_file.html"); + let url4 = browser.extension.getURL("test_file.html"); + + browser.test.assertTrue(url1 !== undefined, "url1 defined"); + + browser.test.assertTrue( + url1.startsWith("moz-extension://"), + "url1 has correct scheme" + ); + browser.test.assertTrue( + url1.endsWith("test_file.html"), + "url1 has correct leaf name" + ); + + browser.test.assertEq(url1, url2, "url2 matches"); + browser.test.assertEq(url1, url3, "url3 matches"); + browser.test.assertEq(url1, url4, "url4 matches"); + + browser.test.notifyPass("geturl"); + }); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + + files: { + "content_script.js"() { + let url1 = browser.runtime.getURL("test_file.html"); + let url2 = browser.extension.getURL("test_file.html"); + browser.runtime.sendMessage([url1, url2]); + }, + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + await extension.awaitFinish("geturl"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js new file mode 100644 index 0000000000..9709df842d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n.js @@ -0,0 +1,574 @@ +"use strict"; + +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +var originalReqLocales = Services.locale.requestedLocales; + +registerCleanupFunction(() => { + Preferences.reset("intl.accept_languages"); + Services.locale.requestedLocales = originalReqLocales; +}); + +add_task(async function test_i18n() { + function runTests(assertEq) { + let _ = browser.i18n.getMessage.bind(browser.i18n); + + let url = browser.runtime.getURL("/"); + assertEq( + url, + `moz-extension://${_("@@extension_id")}/`, + "@@extension_id builtin message" + ); + + assertEq("Foo.", _("Foo"), "Simple message in selected locale."); + + assertEq("(bar)", _("bar"), "Simple message fallback in default locale."); + + assertEq("", _("some-unknown-locale-string"), "Unknown locale string."); + + assertEq("", _("@@unknown_builtin_string"), "Unknown built-in string."); + assertEq( + "", + _("@@bidi_unknown_builtin_string"), + "Unknown built-in bidi string." + ); + + assertEq("Føo.", _("Föo"), "Multi-byte message in selected locale."); + + let substitutions = []; + substitutions[4] = "5"; + substitutions[13] = "14"; + + assertEq( + "'$0' '14' '' '5' '$$$$' '$'.", + _("basic_substitutions", substitutions), + "Basic numeric substitutions" + ); + + assertEq( + "'$0' '' 'just a string' '' '$$$$' '$'.", + _("basic_substitutions", "just a string"), + "Basic numeric substitutions, with non-array value" + ); + + let values = _("named_placeholder_substitutions", [ + "(subst $1 $2)", + "(2 $1 $2)", + ]).split("\n"); + + assertEq( + "_foo_ (subst $1 $2) _bar_", + values[0], + "Named and numeric substitution" + ); + + assertEq( + "(2 $1 $2)", + values[1], + "Numeric substitution amid named placeholders" + ); + + assertEq("$bad name$", values[2], "Named placeholder with invalid key"); + + assertEq("", values[3], "Named placeholder with an invalid value"); + + assertEq( + "Accepted, but shouldn't break.", + values[4], + "Named placeholder with a strange content value" + ); + + assertEq("$foo", values[5], "Non-placeholder token that should be ignored"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + default_locale: "jp", + + content_scripts: [ + { matches: ["http://*/*/file_sample.html"], js: ["content.js"] }, + ], + }, + + files: { + "_locales/en_US/messages.json": { + foo: { + message: "Foo.", + description: "foo", + }, + + föo: { + message: "Føo.", + description: "foo", + }, + + basic_substitutions: { + message: "'$0' '$14' '$1' '$5' '$$$$$' '$$'.", + description: "foo", + }, + + Named_placeholder_substitutions: { + message: + "$Foo$\n$2\n$bad name$\n$bad_value$\n$bad_content_value$\n$foo", + description: "foo", + placeholders: { + foO: { + content: "_foo_ $1 _bar_", + description: "foo", + }, + + "bad name": { + content: "Nope.", + description: "bad name", + }, + + bad_value: "Nope.", + + bad_content_value: { + content: ["Accepted, but shouldn't break."], + description: "bad value", + }, + }, + }, + + broken_placeholders: { + message: "$broken$", + description: "broken placeholders", + placeholders: "foo.", + }, + }, + + "_locales/jp/messages.json": { + foo: { + message: "(foo)", + description: "foo", + }, + + bar: { + message: "(bar)", + description: "bar", + }, + }, + + "content.js": + "new " + + function(runTestsFn) { + runTestsFn((...args) => { + browser.runtime.sendMessage(["assertEq", ...args]); + }); + + browser.runtime.sendMessage(["content-script-finished"]); + } + + `(${runTests})`, + }, + + background: + "new " + + function(runTestsFn) { + browser.runtime.onMessage.addListener(([msg, ...args]) => { + if (msg == "assertEq") { + browser.test.assertEq(...args); + } else { + browser.test.sendMessage(msg, ...args); + } + }); + + runTestsFn(browser.test.assertEq.bind(browser.test)); + } + + `(${runTests})`, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension.awaitMessage("content-script-finished"); + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_i18n_negotiation() { + function runTests(expected) { + let _ = browser.i18n.getMessage.bind(browser.i18n); + + browser.test.assertEq(expected, _("foo"), "Got expected message"); + } + + let extensionData = { + manifest: { + default_locale: "en_US", + + content_scripts: [ + { matches: ["http://*/*/file_sample.html"], js: ["content.js"] }, + ], + }, + + files: { + "_locales/en_US/messages.json": { + foo: { + message: "English.", + description: "foo", + }, + }, + + "_locales/jp/messages.json": { + foo: { + message: "\u65e5\u672c\u8a9e", + description: "foo", + }, + }, + + "content.js": + "new " + + function(runTestsFn) { + browser.test.onMessage.addListener(expected => { + runTestsFn(expected); + + browser.test.sendMessage("content-script-finished"); + }); + browser.test.sendMessage("content-ready"); + } + + `(${runTests})`, + }, + + background: + "new " + + function(runTestsFn) { + browser.test.onMessage.addListener(expected => { + runTestsFn(expected); + + browser.test.sendMessage("background-script-finished"); + }); + } + + `(${runTests})`, + }; + + // At the moment extension language negotiation is tied to Firefox language + // negotiation result. That means that to test an extension in `fr`, we need + // to mock `fr` being available in Firefox and then request it. + // + // In the future, we should provide some way for tests to decouple their + // language selection from that of Firefox. + Services.locale.availableLocales = ["en-US", "fr", "jp"]; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + for (let [lang, msg] of [ + ["en-US", "English."], + ["jp", "\u65e5\u672c\u8a9e"], + ]) { + Services.locale.requestedLocales = [lang]; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("content-ready"); + + extension.sendMessage(msg); + await extension.awaitMessage("background-script-finished"); + await extension.awaitMessage("content-script-finished"); + + await extension.unload(); + } + Services.locale.requestedLocales = originalReqLocales; + + await contentPage.close(); +}); + +add_task(async function test_get_accept_languages() { + function checkResults(source, results, expected) { + browser.test.assertEq( + expected.length, + results.length, + `got expected number of languages in ${source}` + ); + results.forEach((lang, index) => { + browser.test.assertEq( + expected[index], + lang, + `got expected language in ${source}` + ); + }); + } + + function background(checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.getAcceptLanguages().then(results => { + checkResultsFn("background", results, expected); + + browser.test.sendMessage("background-done"); + }); + }); + } + + function content(checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.getAcceptLanguages().then(results => { + checkResultsFn("contentScript", results, expected); + + browser.test.sendMessage("content-done"); + }); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${background})(${checkResults})`, + + files: { + "content_script.js": `(${content})(${checkResults})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + let expectedLangs = ["en-US", "en"]; + extension.sendMessage(["expect-results", expectedLangs]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + expectedLangs = ["en-US", "en", "fr-CA", "fr"]; + Preferences.set("intl.accept_languages", expectedLangs.toString()); + extension.sendMessage(["expect-results", expectedLangs]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + Preferences.reset("intl.accept_languages"); + + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_get_ui_language() { + function getResults() { + return { + getUILanguage: browser.i18n.getUILanguage(), + getMessage: browser.i18n.getMessage("@@ui_locale"), + }; + } + + function checkResults(source, results, expected) { + browser.test.assertEq( + expected, + results.getUILanguage, + `Got expected getUILanguage result in ${source}` + ); + browser.test.assertEq( + expected, + results.getMessage, + `Got expected getMessage result in ${source}` + ); + } + + function background(getResultsFn, checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + checkResultsFn("background", getResultsFn(), expected); + + browser.test.sendMessage("background-done"); + }); + } + + function content(getResultsFn, checkResultsFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + checkResultsFn("contentScript", getResultsFn(), expected); + + browser.test.sendMessage("content-done"); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${background})(${getResults}, ${checkResults})`, + + files: { + "content_script.js": `(${content})(${getResults}, ${checkResults})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + extension.sendMessage(["expect-results", "en-US"]); + + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + // We don't currently have a good way to mock this. + if (false) { + Services.locale.requestedLocales = ["he"]; + + extension.sendMessage(["expect-results", "he"]); + + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + } + + await contentPage.close(); + + await extension.unload(); +}); + +add_task(async function test_detect_language() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // This is not supported on Android. + return; + } + + const af_string = + " aam skukuza die naam beteken hy wat skoonvee of hy wat alles onderstebo keer wysig " + + "bosveldkampe boskampe is kleiner afgeleë ruskampe wat oor min fasiliteite beskik daar is geen restaurante " + + "of winkels nie en slegs oornagbesoekers word toegelaat bateleur"; + // String with intermixed French/English text + const fr_en_string = + "France is the largest country in Western Europe and the third-largest in Europe as a whole. " + + "A accès aux chiens et aux frontaux qui lui ont été il peut consulter et modifier ses collections et exporter " + + "Cet article concerne le pays européen aujourd’hui appelé République française. Pour d’autres usages du nom France, " + + "Pour une aide rapide et effective, veuiller trouver votre aide dans le menu ci-dessus." + + "Motoring events began soon after the construction of the first successful gasoline-fueled automobiles. The quick brown fox jumped over the lazy dog"; + + function checkResult(source, result, expected) { + browser.test.assertEq( + expected.isReliable, + result.isReliable, + "result.confident is true" + ); + browser.test.assertEq( + expected.languages.length, + result.languages.length, + `result.languages contains the expected number of languages in ${source}` + ); + expected.languages.forEach((lang, index) => { + browser.test.assertEq( + lang.percentage, + result.languages[index].percentage, + `element ${index} of result.languages array has the expected percentage in ${source}` + ); + browser.test.assertEq( + lang.language, + result.languages[index].language, + `element ${index} of result.languages array has the expected language in ${source}` + ); + }); + } + + function backgroundScript(checkResultFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.detectLanguage(msg).then(result => { + checkResultFn("background", result, expected); + browser.test.sendMessage("background-done"); + }); + }); + } + + function content(checkResultFn) { + browser.test.onMessage.addListener(([msg, expected]) => { + browser.i18n.detectLanguage(msg).then(result => { + checkResultFn("contentScript", result, expected); + browser.test.sendMessage("content-done"); + }); + }); + browser.test.sendMessage("content-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + + background: `(${backgroundScript})(${checkResult})`, + + files: { + "content_script.js": `(${content})(${checkResult})`, + }, + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.startup(); + await extension.awaitMessage("content-loaded"); + + let expected = { + isReliable: true, + languages: [ + { + language: "fr", + percentage: 67, + }, + { + language: "en", + percentage: 32, + }, + ], + }; + extension.sendMessage([fr_en_string, expected]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + expected = { + isReliable: true, + languages: [ + { + language: "af", + percentage: 99, + }, + ], + }; + extension.sendMessage([af_string, expected]); + await extension.awaitMessage("background-done"); + await extension.awaitMessage("content-done"); + + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js new file mode 100644 index 0000000000..c644ba9782 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_i18n_css.js @@ -0,0 +1,197 @@ +"use strict"; + +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// Some multibyte characters. This sample was taken from the encoding/api-basics.html web platform test. +const MULTIBYTE_STRING = "z\xA2\u6C34\uD834\uDD1E\uF8FF\uDBFF\uDFFD\uFFFE"; +let getCSS = (a, b) => `a { content: '${a}'; } b { content: '${b}'; }`; + +let extensionData = { + background: function() { + function backgroundFetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.overrideMimeType("text/plain"); + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = reject; + xhr.send(); + }); + } + + Promise.all([ + backgroundFetch("foo.css"), + backgroundFetch("bar.CsS?x#y"), + backgroundFetch("foo.txt"), + ]).then(results => { + browser.test.assertEq( + "body { max-width: 42px; }", + results[0], + "CSS file localized" + ); + browser.test.assertEq( + "body { max-width: 42px; }", + results[1], + "CSS file localized" + ); + + browser.test.assertEq( + "body { __MSG_foo__; }", + results[2], + "Text file not localized" + ); + + browser.test.notifyPass("i18n-css"); + }); + + browser.test.sendMessage("ready", browser.runtime.getURL("/")); + }, + + manifest: { + applications: { + gecko: { + id: "i18n_css@mochi.test", + }, + }, + + web_accessible_resources: [ + "foo.css", + "foo.txt", + "locale.css", + "multibyte.css", + ], + + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + css: ["foo.css"], + run_at: "document_start", + }, + { + matches: ["http://*/*/file_sample.html"], + js: ["content.js"], + }, + ], + + default_locale: "en", + }, + + files: { + "_locales/en/messages.json": JSON.stringify({ + foo: { + message: "max-width: 42px", + description: "foo", + }, + multibyteKey: { + message: MULTIBYTE_STRING, + }, + }), + + "content.js": function() { + let style = getComputedStyle(document.body); + browser.test.sendMessage("content-maxWidth", style.maxWidth); + }, + + "foo.css": "body { __MSG_foo__; }", + "bar.CsS": "body { __MSG_foo__; }", + "foo.txt": "body { __MSG_foo__; }", + "locale.css": + '* { content: "__MSG_@@ui_locale__ __MSG_@@bidi_dir__ __MSG_@@bidi_reversed_dir__ __MSG_@@bidi_start_edge__ __MSG_@@bidi_end_edge__" }', + "multibyte.css": getCSS("__MSG_multibyteKey__", MULTIBYTE_STRING), + }, +}; + +async function test_i18n_css(options = {}) { + extensionData.useAddonManager = options.useAddonManager; + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let baseURL = await extension.awaitMessage("ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + let css = await contentPage.fetch(baseURL + "foo.css"); + + equal( + css, + "body { max-width: 42px; }", + "CSS file localized in mochitest scope" + ); + + let maxWidth = await extension.awaitMessage("content-maxWidth"); + + equal(maxWidth, "42px", "stylesheet correctly applied"); + + css = await contentPage.fetch(baseURL + "locale.css"); + equal( + css, + '* { content: "en-US ltr rtl left right" }', + "CSS file localized in mochitest scope" + ); + + css = await contentPage.fetch(baseURL + "multibyte.css"); + equal( + css, + getCSS(MULTIBYTE_STRING, MULTIBYTE_STRING), + "CSS file contains multibyte string" + ); + + await contentPage.close(); + + // We don't currently have a good way to mock this. + if (false) { + const DIR = "intl.l10n.pseudo"; + + // We don't wind up actually switching the chrome registry locale, since we + // don't have a chrome package for Hebrew. So just override it, and force + // RTL directionality. + const origReqLocales = Services.locale.requestedLocales; + Services.locale.requestedLocales = ["he"]; + Preferences.set(DIR, "bidi"); + + css = await fetch(baseURL + "locale.css"); + equal( + css, + '* { content: "he rtl ltr right left" }', + "CSS file localized in mochitest scope" + ); + + Services.locale.requestedLocales = origReqLocales; + Preferences.reset(DIR); + } + + await extension.awaitFinish("i18n-css"); + await extension.unload(); +} + +add_task(async function startup() { + await promiseStartupManager(); +}); +add_task(test_i18n_css); +add_task(async function test_i18n_css_xpi() { + await test_i18n_css({ useAddonManager: "temporary" }); +}); +add_task(async function startup() { + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_idle.js b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js new file mode 100644 index 0000000000..8225278a7f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_idle.js @@ -0,0 +1,270 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { MockRegistrar } = ChromeUtils.import( + "resource://testing-common/MockRegistrar.jsm" +); + +let idleService = { + _observers: new Set(), + _activity: { + addCalls: [], + removeCalls: [], + observerFires: [], + }, + _reset: function() { + this._observers.clear(); + this._activity.addCalls = []; + this._activity.removeCalls = []; + this._activity.observerFires = []; + }, + _fireObservers: function(state) { + for (let observer of this._observers.values()) { + observer.observe(observer, state, null); + this._activity.observerFires.push(state); + } + }, + QueryInterface: ChromeUtils.generateQI(["nsIUserIdleService"]), + idleTime: 19999, + addIdleObserver: function(observer, time) { + this._observers.add(observer); + this._activity.addCalls.push(time); + }, + removeIdleObserver: function(observer, time) { + this._observers.delete(observer); + this._activity.removeCalls.push(time); + }, +}; + +function checkActivity(expectedActivity) { + let { expectedAdd, expectedRemove, expectedFires } = expectedActivity; + let { addCalls, removeCalls, observerFires } = idleService._activity; + equal( + expectedAdd.length, + addCalls.length, + "idleService.addIdleObserver was called the expected number of times" + ); + equal( + expectedRemove.length, + removeCalls.length, + "idleService.removeIdleObserver was called the expected number of times" + ); + equal( + expectedFires.length, + observerFires.length, + "idle observer was fired the expected number of times" + ); + deepEqual( + addCalls, + expectedAdd, + "expected interval passed to idleService.addIdleObserver" + ); + deepEqual( + removeCalls, + expectedRemove, + "expected interval passed to idleService.removeIdleObserver" + ); + deepEqual( + observerFires, + expectedFires, + "expected topic passed to idle observer" + ); +} + +add_task(async function setup() { + let fakeIdleService = MockRegistrar.register( + "@mozilla.org/widget/useridleservice;1", + idleService + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakeIdleService); + }); +}); + +add_task(async function testQueryStateActive() { + function background() { + browser.idle.queryState(20).then( + status => { + browser.test.assertEq("active", status, "Idle status is active"); + browser.test.notifyPass("idle"); + }, + err => { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("idle"); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +add_task(async function testQueryStateIdle() { + function background() { + browser.idle.queryState(15).then( + status => { + browser.test.assertEq("idle", status, "Idle status is idle"); + browser.test.notifyPass("idle"); + }, + err => { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("idle"); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("idle"); + await extension.unload(); +}); + +add_task(async function testOnlySetDetectionInterval() { + function background() { + browser.idle.setDetectionInterval(99); + browser.test.sendMessage("detectionIntervalSet"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("detectionIntervalSet"); + idleService._fireObservers("idle"); + checkActivity({ expectedAdd: [], expectedRemove: [], expectedFires: [] }); + await extension.unload(); +}); + +add_task(async function testSetDetectionIntervalBeforeAddingListener() { + function background() { + browser.idle.setDetectionInterval(99); + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "idle", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("listenerAdded"); + idleService._fireObservers("idle"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [99], + expectedRemove: [], + expectedFires: ["idle"], + }); + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function testSetDetectionIntervalAfterAddingListener() { + function background() { + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "idle", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.idle.setDetectionInterval(99); + browser.test.sendMessage("detectionIntervalSet"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("detectionIntervalSet"); + idleService._fireObservers("idle"); + await extension.awaitMessage("listenerFired"); + checkActivity({ + expectedAdd: [60, 99], + expectedRemove: [60], + expectedFires: ["idle"], + }); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); + +add_task(async function testOnlyAddingListener() { + function background() { + browser.idle.onStateChanged.addListener(newState => { + browser.test.assertEq( + "active", + newState, + "listener fired with the expected state" + ); + browser.test.sendMessage("listenerFired"); + }); + browser.test.sendMessage("listenerAdded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["idle"], + }, + }); + + idleService._reset(); + await extension.startup(); + await extension.awaitMessage("listenerAdded"); + idleService._fireObservers("active"); + await extension.awaitMessage("listenerFired"); + // check that "idle-daily" topic does not cause a listener to fire + idleService._fireObservers("idle-daily"); + checkActivity({ + expectedAdd: [60], + expectedRemove: [], + expectedFires: ["active", "idle-daily"], + }); + + // Defer unloading the extension so the asynchronous event listener + // reply finishes. + await new Promise(resolve => setTimeout(resolve, 0)); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js new file mode 100644 index 0000000000..9b17a633e9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_incognito.js @@ -0,0 +1,302 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); + +// Assert on the expected "addonsManager.action" telemetry events (and optional filter events to verify +// by using a given actionType). +function assertActionAMTelemetryEvent( + expectedActionEvents, + assertMessage, + { actionType } = {} +) { + const snapshot = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ); + + ok( + snapshot.parent && !!snapshot.parent.length, + "Got parent telemetry events in the snapshot" + ); + + const events = snapshot.parent + .filter(([timestamp, category, method, object, value, extra]) => { + return ( + category === "addonsManager" && + method === "action" && + (!actionType ? true : extra && extra.action === actionType) + ); + }) + .map(([timestamp, category, method, object, value, extra]) => { + return { method, object, value, extra }; + }); + + Assert.deepEqual(events, expectedActionEvents, assertMessage); +} + +async function runIncognitoTest( + extensionData, + privateBrowsingAllowed, + allowPrivateBrowsingByDefault +) { + Services.prefs.setBoolPref( + "extensions.allowPrivateBrowsingByDefault", + allowPrivateBrowsingByDefault + ); + + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + let { extension } = wrapper; + + if (!allowPrivateBrowsingByDefault) { + // Check the permission if we're not allowPrivateBrowsingByDefault. + equal( + extension.permissions.has("internal:privateBrowsingAllowed"), + privateBrowsingAllowed, + "privateBrowsingAllowed in serialized extension" + ); + } + equal( + extension.privateBrowsingAllowed, + privateBrowsingAllowed, + "privateBrowsingAllowed in extension" + ); + equal( + extension.policy.privateBrowsingAllowed, + privateBrowsingAllowed, + "privateBrowsingAllowed on policy" + ); + + await wrapper.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +} + +add_task(async function test_extension_incognito_spanning() { + await runIncognitoTest({}, false, false); + await runIncognitoTest({}, true, true); +}); + +// Test that when we are restricted, we can override the restriction for tests. +add_task(async function test_extension_incognito_override_spanning() { + let extensionData = { + incognitoOverride: "spanning", + }; + await runIncognitoTest(extensionData, true, false); +}); + +// This tests that a privileged extension will always have private browsing. +add_task(async function test_extension_incognito_privileged() { + let extensionData = { + isPrivileged: true, + }; + await runIncognitoTest(extensionData, true, true); + await runIncognitoTest(extensionData, true, false); +}); + +// We only test spanning upgrades since that is the only allowed +// incognito type prior to feature being turned on. +add_task(async function test_extension_incognito_spanning_grandfathered() { + await AddonTestUtils.promiseStartupManager(); + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", true); + Services.prefs.setBoolPref("extensions.incognito.migrated", false); + + // This extension gets disabled before the "upgrade", it should not + // get grandfathered permissions. + const disabledAddonId = "disabled-ext@mozilla.com"; + let disabledWrapper = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: disabledAddonId } }, + incognito: "spanning", + }, + useAddonManager: "permanent", + }); + await disabledWrapper.startup(); + let disabledPolicy = WebExtensionPolicy.getByID(disabledAddonId); + + // Verify policy settings. + equal( + disabledPolicy.permissions.includes("internal:privateBrowsingAllowed"), + false, + "privateBrowsingAllowed is not in permissions for disabled addon" + ); + equal( + disabledPolicy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in disabled addon" + ); + + let disabledAddon = await AddonManager.getAddonByID(disabledAddonId); + await disabledAddon.disable(); + + // This extension gets grandfathered permissions for private browsing. + let addonId = "grandfathered@mozilla.com"; + let wrapper = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: addonId } }, + incognito: "spanning", + }, + useAddonManager: "permanent", + }); + await wrapper.startup(); + let policy = WebExtensionPolicy.getByID(addonId); + + // Verify policy settings. + equal( + policy.permissions.includes("internal:privateBrowsingAllowed"), + false, + "privateBrowsingAllowed is not in permissions" + ); + equal( + policy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in extension" + ); + + // Turn on incognito support and update the browser. + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + // Disable the addonsManager telemetry event category, to ensure that it will + // be enabled automatically during the AddonManager/XPIProvider startup and + // the telemetry event recorded (See Bug 1540112 for a rationale). + Services.telemetry.setEventRecordingEnabled("addonsManager", false); + await AddonTestUtils.promiseRestartManager("2"); + await wrapper.awaitStartup(); + + // Did it upgrade? + ok( + Services.prefs.getBoolPref("extensions.incognito.migrated", false), + "pref marked as migrated" + ); + + // Verify policy settings. + policy = WebExtensionPolicy.getByID(addonId); + ok( + policy.permissions.includes("internal:privateBrowsingAllowed"), + "privateBrowsingAllowed is in permissions" + ); + equal( + policy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in extension" + ); + + // Verify the disabled addon did not get permissions. + disabledAddon = await AddonManager.getAddonByID(disabledAddonId); + await disabledAddon.enable(); + disabledPolicy = WebExtensionPolicy.getByID(disabledAddonId); + + // Verify policy settings. + equal( + disabledPolicy.permissions.includes("internal:privateBrowsingAllowed"), + false, + "privateBrowsingAllowed is not in permissions for disabled addon" + ); + equal( + disabledPolicy.privateBrowsingAllowed, + false, + "privateBrowsingAllowed in disabled addon" + ); + + await wrapper.unload(); + await disabledWrapper.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); + Services.prefs.clearUserPref("extensions.incognito.migrated"); + + const expectedEvents = [ + { + method: "action", + object: "appUpgrade", + value: "on", + extra: { addonId, action: "privateBrowsingAllowed" }, + }, + ]; + + assertActionAMTelemetryEvent( + expectedEvents, + "Got the expected telemetry events for the grandfathered extensions", + { actionType: "privateBrowsingAllowed" } + ); +}); + +add_task(async function test_extension_privileged_not_allowed() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + let addonId = "privileged_not_allowed@mochi.test"; + let extensionData = { + manifest: { + version: "1.0", + applications: { gecko: { id: addonId } }, + incognito: "not_allowed", + }, + useAddonManager: "permanent", + isPrivileged: true, + }; + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + let policy = WebExtensionPolicy.getByID(addonId); + equal( + policy.extension.isPrivileged, + true, + "The test extension is privileged" + ); + equal( + policy.privateBrowsingAllowed, + false, + "privateBrowsingAllowed is false" + ); + + await wrapper.unload(); +}); + +// Test that we remove pb permission if an extension is updated to not_allowed. +add_task(async function test_extension_upgrade_not_allowed() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + let addonId = "upgrade@mochi.test"; + let extensionData = { + manifest: { + version: "1.0", + applications: { gecko: { id: addonId } }, + incognito: "spanning", + }, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }; + let wrapper = ExtensionTestUtils.loadExtension(extensionData); + await wrapper.startup(); + + let policy = WebExtensionPolicy.getByID(addonId); + + equal( + policy.privateBrowsingAllowed, + true, + "privateBrowsingAllowed in extension" + ); + + extensionData.manifest.version = "2.0"; + extensionData.manifest.incognito = "not_allowed"; + await wrapper.upgrade(extensionData); + + equal(wrapper.version, "2.0", "Expected extension version"); + policy = WebExtensionPolicy.getByID(addonId); + equal( + policy.privateBrowsingAllowed, + false, + "privateBrowsingAllowed is false" + ); + + await wrapper.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js new file mode 100644 index 0000000000..e520c48f26 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_indexedDB_principal.js @@ -0,0 +1,101 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function test_indexedDB_principal() { + Services.prefs.setBoolPref("privacy.firstparty.isolate", true); + + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + async background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "create-storage") { + let request = window.indexedDB.open("TestDatabase"); + request.onupgradeneeded = function(e) { + let db = e.target.result; + db.createObjectStore("TestStore"); + }; + request.onsuccess = function(e) { + let db = e.target.result; + let tx = db.transaction("TestStore", "readwrite"); + let store = tx.objectStore("TestStore"); + tx.oncomplete = () => browser.test.sendMessage("storage-created"); + store.add("foo", "bar"); + tx.onerror = function(e) { + browser.test.fail(`Failed with error ${tx.error.message}`); + // Don't wait for timeout + browser.test.sendMessage("storage-created"); + }; + }; + request.onerror = function(e) { + browser.test.fail(`Failed with error ${request.error.message}`); + // Don't wait for timeout + browser.test.sendMessage("storage-created"); + }; + return; + } + if (msg == "check-storage") { + let dbRequest = window.indexedDB.open("TestDatabase"); + dbRequest.onupgradeneeded = function() { + browser.test.fail("Database should exist"); + browser.test.notifyFail("done"); + }; + dbRequest.onsuccess = function(e) { + let db = e.target.result; + let transaction = db.transaction("TestStore"); + transaction.onerror = function(e) { + browser.test.fail( + `Failed with error ${transaction.error.message}` + ); + browser.test.notifyFail("done"); + }; + let objectStore = transaction.objectStore("TestStore"); + let request = objectStore.get("bar"); + request.onsuccess = function(event) { + browser.test.assertEq( + request.result, + "foo", + "Got the expected data" + ); + browser.test.notifyPass("done"); + }; + request.onerror = function(e) { + browser.test.fail(`Failed with error ${request.error.message}`); + browser.test.notifyFail("done"); + }; + }; + dbRequest.onerror = function(e) { + browser.test.fail(`Failed with error ${dbRequest.error.message}`); + browser.test.notifyFail("done"); + }; + } + }); + }, + }); + + await extension.startup(); + extension.sendMessage("create-storage"); + await extension.awaitMessage("storage-created"); + + await extension.addon.disable(); + + Services.prefs.setBoolPref("privacy.firstparty.isolate", false); + + await extension.addon.enable(); + await extension.awaitStartup(); + + extension.sendMessage("check-storage"); + await extension.awaitFinish("done"); + + await extension.unload(); + Services.prefs.clearUserPref("privacy.firstparty.isolate"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js new file mode 100644 index 0000000000..dd90d9bbc8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_ipcBlob.js @@ -0,0 +1,150 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +add_task(async function test_parent_to_child() { + async function background() { + const dbName = "broken-blob"; + const dbStore = "blob-store"; + const dbVersion = 1; + const blobContent = "Hello World!"; + + let db = await new Promise((resolve, reject) => { + let dbOpen = indexedDB.open(dbName, dbVersion); + dbOpen.onerror = event => { + browser.test.fail(`Error opening the DB: ${event.target.error}`); + browser.test.notifyFail("test-completed"); + reject(); + }; + dbOpen.onsuccess = event => { + resolve(event.target.result); + }; + dbOpen.onupgradeneeded = event => { + let dbobj = event.target.result; + dbobj.onerror = error => { + browser.test.fail(`Error updating the DB: ${error.target.error}`); + browser.test.notifyFail("test-completed"); + reject(); + }; + dbobj.createObjectStore(dbStore); + }; + }); + + async function save(blob) { + let txn = db.transaction([dbStore], "readwrite"); + let store = txn.objectStore(dbStore); + let req = store.put(blob, "key"); + + return new Promise((resolve, reject) => { + req.onsuccess = () => { + resolve(); + }; + req.onerror = event => { + browser.test.fail( + `Error saving the blob into the DB: ${event.target.error}` + ); + browser.test.notifyFail("test-completed"); + reject(); + }; + }); + } + + async function load() { + let txn = db.transaction([dbStore], "readonly"); + let store = txn.objectStore(dbStore); + let req = store.getAll(); + + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }) + .then(loadDetails => { + let blobs = []; + loadDetails.forEach(details => { + blobs.push(details); + }); + return blobs[0]; + }) + .catch(err => { + browser.test.fail( + `Error loading the blob from the DB: ${err} :: ${err.stack}` + ); + browser.test.notifyFail("test-completed"); + }); + } + + browser.test.log("Blob creation"); + await save(new Blob([blobContent])); + let blob = await load(); + + db.close(); + + browser.runtime.onMessage.addListener(([msg, what]) => { + browser.test.log("Message received from content: " + msg); + if (msg == "script-ready") { + return Promise.resolve({ blob }); + } + + if (msg == "script-value") { + browser.test.assertEq(blobContent, what, "blob content matches"); + browser.test.notifyPass("test-completed"); + return; + } + + browser.test.fail(`Unexpected test message received: ${msg}`); + }); + + browser.test.sendMessage("bg-ready"); + } + + function contentScriptStart() { + browser.runtime.sendMessage(["script-ready"], response => { + let reader = new FileReader(); + reader.addEventListener( + "load", + () => { + browser.runtime.sendMessage(["script-value", reader.result]); + }, + { once: true } + ); + reader.readAsText(response.blob); + }); + } + + let extensionData = { + background, + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script_start.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script_start.js": contentScriptStart, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("bg-ready"); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish("test-completed"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js new file mode 100644 index 0000000000..728df04c60 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_json_parser.js @@ -0,0 +1,39 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_json_parser() { + const ID = "json@test.web.extension"; + + let xpi = AddonTestUtils.createTempWebExtensionFile({ + files: { + "manifest.json": String.raw`{ + // This is a manifest. + "applications": {"gecko": {"id": "${ID}"}}, + "name": "This \" is // not a comment", + "version": "0.1\\" // , "description": "This is not a description" + }`, + }, + }); + + let expectedManifest = { + applications: { gecko: { id: ID } }, + name: 'This " is // not a comment', + version: "0.1\\", + }; + + let fileURI = Services.io.newFileURI(xpi); + let uri = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(uri); + + await extension.parseManifest(); + + Assert.deepEqual( + extension.rawManifest, + expectedManifest, + "Manifest with correctly-filtered comments" + ); + + Services.obs.notifyObservers(xpi, "flush-cache-entry"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js new file mode 100644 index 0000000000..b8eb3830fa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_l10n.js @@ -0,0 +1,150 @@ +"use strict"; + +const { L10nRegistry, FileSource } = ChromeUtils.import( + "resource://gre/modules/L10nRegistry.jsm" +); +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +add_task(async function setup() { + // Add a test .ftl file + // (Note: other tests do this by patching L10nRegistry.load() but in + // this test L10nRegistry is also loaded in the extension process -- + // just adding a new resource is easier than trying to patch + // L10nRegistry in all processes) + let dir = FileUtils.getDir("TmpD", ["l10ntest"]); + dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + await OS.File.writeAtomic( + OS.Path.join(dir.path, "test.ftl"), + "key = value\n" + ); + + let target = Services.io.newFileURI(dir); + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + + resProto.setSubstitution("l10ntest", target); + + const source = new FileSource( + "test", + Services.locale.requestedLocales, + "resource://l10ntest/" + ); + L10nRegistry.registerSources([source]); +}); + +// Test that privileged extensions can use fluent to get strings from +// language packs (and that unprivileged extensions cannot) +add_task(async function test_l10n_dom() { + const PAGE = `<!DOCTYPE html> + <html><head> + <meta charset="utf8"> + <link rel="localization" href="test.ftl"/> + <script src="page.js"></script> + </head></html>`; + + function SCRIPT() { + window.addEventListener( + "load", + async () => { + try { + await document.l10n.ready; + let result = await document.l10n.formatValue("key"); + browser.test.sendMessage("result", { success: true, result }); + } catch (err) { + browser.test.sendMessage("result", { + success: false, + msg: err.message, + }); + } + }, + { once: true } + ); + } + + async function runTest(isPrivileged) { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + manifest: { + web_accessible_resources: ["page.html"], + }, + isPrivileged, + files: { + "page.html": PAGE, + "page.js": SCRIPT, + }, + }); + + await extension.startup(); + let url = await extension.awaitMessage("ready"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + let results = await extension.awaitMessage("result"); + await page.close(); + await extension.unload(); + + return results; + } + + // Everything should work for a privileged extension + let results = await runTest(true); + equal(results.success, true, "Translation succeeded in privileged extension"); + equal(results.result, "value", "Translation got the right value"); + + // In an unprivleged extension, document.l10n shouldn't show up + results = await runTest(false); + equal(results.success, false, "Translation failed in unprivileged extension"); + equal( + results.msg.endsWith("document.l10n is undefined"), + true, + "Translation failed due to missing document.l10n" + ); +}); + +add_task(async function test_l10n_manifest() { + // Fluent can't be used to localize properties that the AddonManager + // reads (see comment inside ExtensionData.parseManifest for details) + // so test by localizing a property that only the extension framework + // cares about: page_action. This means we can only do this test from + // browser. + if (AppConstants.MOZ_BUILD_APP != "browser") { + return; + } + + AddonTestUtils.initializeURLPreloader(); + + async function runTest(isPrivileged) { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged, + manifest: { + l10n_resources: ["test.ftl"], + page_action: { + default_title: "__MSG_key__", + }, + }, + }); + + await extension.startup(); + let title = extension.extension.manifest.page_action.default_title; + await extension.unload(); + return title; + } + + let title = await runTest(true); + equal( + title, + "value", + "Manifest key localized with fluent in privileged extension" + ); + title = await runTest(false); + equal( + title, + "__MSG_key__", + "Manifest key not localized in unprivileged extension" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js new file mode 100644 index 0000000000..9ae4a4a873 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_localStorage.js @@ -0,0 +1,50 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + let hasRun = localStorage.getItem("has-run"); + let result; + if (!hasRun) { + localStorage.setItem("has-run", "yup"); + localStorage.setItem("test-item", "item1"); + result = "item1"; + } else { + let data = localStorage.getItem("test-item"); + if (data == "item1") { + localStorage.setItem("test-item", "item2"); + result = "item2"; + } else if (data == "item2") { + localStorage.removeItem("test-item"); + result = "deleted"; + } else if (!data) { + localStorage.clear(); + result = "cleared"; + } + } + browser.test.sendMessage("result", result); + browser.test.notifyPass("localStorage"); +} + +const ID = "test-webextension@mozilla.com"; +let extensionData = { + manifest: { applications: { gecko: { id: ID } } }, + background: backgroundScript, +}; + +add_task(async function test_localStorage() { + const RESULTS = ["item1", "item2", "deleted", "cleared", "item1"]; + + for (let expected of RESULTS) { + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + let actual = await extension.awaitMessage("result"); + + await extension.awaitFinish("localStorage"); + await extension.unload(); + + equal(actual, expected, "got expected localStorage data"); + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management.js b/toolkit/components/extensions/test/xpcshell/test_ext_management.js new file mode 100644 index 0000000000..c6c73b2249 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management.js @@ -0,0 +1,205 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +add_task(async function setup() { + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_management_permission() { + async function background() { + const permObj = { permissions: ["management"] }; + + let hasPerm = await browser.permissions.contains(permObj); + browser.test.assertTrue(!hasPerm, "does not have management permission"); + browser.test.assertTrue( + !!browser.management, + "management namespace exists" + ); + // These require permission + let requires_permission = [ + "getAll", + "get", + "install", + "setEnabled", + "onDisabled", + "onEnabled", + "onInstalled", + "onUninstalled", + ]; + + async function testAvailable() { + // These are always available regardless of permission. + for (let fn of ["getSelf", "uninstallSelf"]) { + browser.test.assertTrue( + !!browser.management[fn], + `management.${fn} exists` + ); + } + + let hasPerm = await browser.permissions.contains(permObj); + for (let fn of requires_permission) { + browser.test.assertEq( + hasPerm, + !!browser.management[fn], + `management.${fn} does not exist` + ); + } + } + + await testAvailable(); + + browser.test.onMessage.addListener(async msg => { + browser.test.log("test with permission"); + + // get permission + await browser.permissions.request(permObj); + let hasPerm = await browser.permissions.contains(permObj); + browser.test.assertTrue( + hasPerm, + "management permission.request accepted" + ); + await testAvailable(); + + browser.management.onInstalled.addListener(() => { + browser.test.fail("onInstalled listener invoked"); + }); + + browser.test.log("test without permission"); + // remove permission + await browser.permissions.remove(permObj); + hasPerm = await browser.permissions.contains(permObj); + browser.test.assertFalse( + hasPerm, + "management permission.request removed" + ); + await testAvailable(); + + browser.test.sendMessage("done"); + }); + + browser.test.sendMessage("started"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "management@test", + }, + }, + optional_permissions: ["management"], + }, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + await extension.awaitMessage("started"); + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + }); + await extension.awaitMessage("done"); + + // Verify the onInstalled listener does not get used. + // The listener will make the test fail if fired. + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { + id: "on-installed@test", + }, + }, + optional_permissions: ["management"], + }, + useAddonManager: "temporary", + }); + await ext2.startup(); + await ext2.unload(); + + await extension.unload(); +}); + +add_task(async function test_management_getAll() { + const id1 = "get_all_test1@tests.mozilla.com"; + const id2 = "get_all_test2@tests.mozilla.com"; + + function getManifest(id) { + return { + applications: { + gecko: { + id, + }, + }, + name: id, + version: "1.0", + short_name: id, + permissions: ["management"], + }; + } + + async function background() { + browser.test.onMessage.addListener(async (msg, id) => { + let addon = await browser.management.get(id); + browser.test.sendMessage("addon", addon); + }); + + let addons = await browser.management.getAll(); + browser.test.assertEq( + 2, + addons.length, + "management.getAll returned correct number of add-ons." + ); + browser.test.sendMessage("addons", addons); + } + + let extension1 = ExtensionTestUtils.loadExtension({ + manifest: getManifest(id1), + useAddonManager: "temporary", + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: getManifest(id2), + background, + useAddonManager: "temporary", + }); + + await extension1.startup(); + await extension2.startup(); + + let addons = await extension2.awaitMessage("addons"); + for (let id of [id1, id2]) { + let addon = addons.find(a => { + return a.id === id; + }); + equal( + addon.name, + id, + `The extension with id ${id} was returned by getAll.` + ); + equal(addon.shortName, id, "Additional extension metadata was correct"); + } + + extension2.sendMessage("getAddon", id1); + let addon = await extension2.awaitMessage("addon"); + equal(addon.name, id1, `The extension with id ${id1} was returned by get.`); + equal(addon.shortName, id1, "Additional extension metadata was correct"); + + extension2.sendMessage("getAddon", id2); + addon = await extension2.awaitMessage("addon"); + equal(addon.name, id2, `The extension with id ${id2} was returned by get.`); + equal(addon.shortName, id2, "Additional extension metadata was correct"); + + await extension2.unload(); + await extension1.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js new file mode 100644 index 0000000000..caed4f5525 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_management_uninstall_self.js @@ -0,0 +1,146 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { MockRegistrar } = ChromeUtils.import( + "resource://testing-common/MockRegistrar.jsm" +); + +const id = "uninstall_self_test@tests.mozilla.com"; + +const manifest = { + applications: { + gecko: { + id, + }, + }, + name: "test extension name", + version: "1.0", +}; + +const waitForUninstalled = () => + new Promise(resolve => { + const listener = { + onUninstalled: async addon => { + equal(addon.id, id, "The expected add-on has been uninstalled"); + let checkedAddon = await AddonManager.getAddonByID(addon.id); + equal(checkedAddon, null, "Add-on no longer exists"); + AddonManager.removeAddonListener(listener); + resolve(); + }, + }; + AddonManager.addAddonListener(listener); + }); + +let promptService = { + _response: null, + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx: function(...args) { + this._confirmExArgs = args; + return this._response; + }, +}; + +AddonTestUtils.init(this); + +add_task(async function setup() { + let fakePromptService = MockRegistrar.register( + "@mozilla.org/embedcomp/prompt-service;1", + promptService + ); + registerCleanupFunction(() => { + MockRegistrar.unregister(fakePromptService); + }); + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_management_uninstall_no_prompt() { + function background() { + browser.test.onMessage.addListener(msg => { + browser.management.uninstallSelf(); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + extension.sendMessage("uninstall"); + await waitForUninstalled(); + Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry"); +}); + +add_task(async function test_management_uninstall_prompt_uninstall() { + promptService._response = 0; + + function background() { + browser.test.onMessage.addListener(msg => { + browser.management.uninstallSelf({ showConfirmDialog: true }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + extension.sendMessage("uninstall"); + await waitForUninstalled(); + + // Test localization strings + equal(promptService._confirmExArgs[1], `Uninstall ${manifest.name}`); + equal( + promptService._confirmExArgs[2], + `The extension “${manifest.name}” is requesting to be uninstalled. What would you like to do?` + ); + equal(promptService._confirmExArgs[4], "Uninstall"); + equal(promptService._confirmExArgs[5], "Keep Installed"); + Services.obs.notifyObservers(extension.extension.file, "flush-cache-entry"); +}); + +add_task(async function test_management_uninstall_prompt_keep() { + promptService._response = 1; + + function background() { + browser.test.onMessage.addListener(async msg => { + await browser.test.assertRejects( + browser.management.uninstallSelf({ showConfirmDialog: true }), + "User cancelled uninstall of extension", + "Expected rejection when user declines uninstall" + ); + + browser.test.sendMessage("uninstall-rejected"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + useAddonManager: "temporary", + }); + + await extension.startup(); + + let addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on is installed"); + + extension.sendMessage("uninstall"); + await extension.awaitMessage("uninstall-rejected"); + + addon = await AddonManager.getAddonByID(id); + notEqual(addon, null, "Add-on remains installed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js new file mode 100644 index 0000000000..cf6749f7a8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest.js @@ -0,0 +1,95 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testIconPaths(icon, manifest, expectedError) { + let normalized = await ExtensionTestUtils.normalizeManifest(manifest); + + if (expectedError) { + ok( + expectedError.test(normalized.error), + `Should have an error for ${JSON.stringify(icon)}` + ); + } else { + ok(!normalized.error, `Should not have an error ${JSON.stringify(icon)}`); + } +} + +add_task(async function test_manifest() { + let badpaths = ["", " ", "\t", "http://foo.com/icon.png"]; + for (let path of badpaths) { + await testIconPaths( + path, + { + icons: path, + }, + /Error processing icons/ + ); + + await testIconPaths( + path, + { + icons: { + "16": path, + }, + }, + /Error processing icons/ + ); + } + + let paths = [ + "icon.png", + "/icon.png", + "./icon.png", + "path to an icon.png", + " icon.png", + ]; + for (let path of paths) { + // manifest.icons is an object + await testIconPaths( + path, + { + icons: path, + }, + /Error processing icons/ + ); + + await testIconPaths(path, { + icons: { + "16": path, + }, + }); + } +}); + +add_task(async function test_manifest_warnings_on_unexpected_props() { + let extension = await ExtensionTestUtils.loadExtension({ + manifest: { + background: { + scripts: ["bg.js"], + wrong_prop: true, + }, + }, + files: { + "bg.js": "", + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + // Retrieve the warning message collected by the Extension class + // packagingWarning method. + const { warnings } = extension.extension; + equal(warnings.length, 1, "Got the expected number of manifest warnings"); + + const expectedMessage = + "Reading manifest: Warning processing background.wrong_prop"; + ok( + warnings[0].startsWith(expectedMessage), + "Got the expected warning message format" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js new file mode 100644 index 0000000000..92dd5ee821 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_content_security_policy.js @@ -0,0 +1,82 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +add_task(async function test_manifest_csp() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + content_security_policy: "script-src 'self'; object-src 'none'", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.content_security_policy, + "script-src 'self'; object-src 'none'", + "Should have the expected policy string" + ); + + ExtensionTestUtils.failOnSchemaWarnings(false); + normalized = await ExtensionTestUtils.normalizeManifest({ + content_security_policy: "object-src 'none'", + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + equal(normalized.error, undefined, "Should not have an error"); + + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy: Policy is missing a required ‘script-src’ directive", + ], + "Should have the expected warning" + ); + + equal( + normalized.value.content_security_policy, + null, + "Invalid policy string should be omitted" + ); +}); + +add_task(async function test_manifest_csp_v3() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + content_security_policy: { + extension_pages: "script-src 'self' 'unsafe-eval'; object-src 'none'", + }, + }); + + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy.extension_pages: ‘script-src’ directive contains a forbidden 'unsafe-eval' keyword", + ], + "Should have the expected warning" + ); + equal( + normalized.value.content_security_policy.extension_pages, + null, + "Should have the expected policy string" + ); + + ExtensionTestUtils.failOnSchemaWarnings(false); + normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + content_security_policy: { + extension_pages: "object-src 'none'", + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 1, "Should have warnings"); + Assert.deepEqual( + normalized.errors, + [ + "Error processing content_security_policy.extension_pages: Policy is missing a required ‘script-src’ directive", + ], + "Should have the expected warning for extension_pages CSP" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js new file mode 100644 index 0000000000..5aa44c5885 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_incognito.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_incognito() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + let normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "spanning", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.incognito, + "spanning", + "Should have the expected incognito string" + ); + + normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "not_allowed", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + equal( + normalized.value.incognito, + "not_allowed", + "Should have the expected incognito string" + ); + + normalized = await ExtensionTestUtils.normalizeManifest({ + incognito: "split", + }); + + equal( + normalized.error, + 'Error processing incognito: Invalid enumeration value "split"', + "Should have an error" + ); + Assert.deepEqual(normalized.errors, [], "Should not have a warning"); + equal( + normalized.value, + undefined, + "Invalid incognito string should be undefined" + ); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js new file mode 100644 index 0000000000..39119513fb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_chrome_version.js @@ -0,0 +1,12 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_minimum_chrome_version() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + minimum_chrome_version: "42", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js new file mode 100644 index 0000000000..943e8b7270 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_minimum_opera_version.js @@ -0,0 +1,12 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_minimum_opera_version() { + let normalized = await ExtensionTestUtils.normalizeManifest({ + minimum_opera_version: "48", + }); + + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js new file mode 100644 index 0000000000..8cd44f06dc --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_manifest_themes.js @@ -0,0 +1,35 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function test_theme_property(property) { + let normalized = await ExtensionTestUtils.normalizeManifest( + { + theme: { + [property]: {}, + }, + }, + "manifest.ThemeManifest" + ); + + if (property === "unrecognized_key") { + const expectedWarning = `Warning processing theme.${property}`; + ok( + normalized.errors[0].includes(expectedWarning), + `The manifest warning ${JSON.stringify( + normalized.errors[0] + )} must contain ${JSON.stringify(expectedWarning)}` + ); + } else { + equal(normalized.errors.length, 0, "Should have a warning"); + } + equal(normalized.error, undefined, "Should not have an error"); +} + +add_task(async function test_manifest_themes() { + await test_theme_property("images"); + await test_theme_property("colors"); + ExtensionTestUtils.failOnSchemaWarnings(false); + await test_theme_property("unrecognized_key"); + ExtensionTestUtils.failOnSchemaWarnings(true); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js new file mode 100644 index 0000000000..c629c51509 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_messaging_startup.js @@ -0,0 +1,270 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +const PAGE_HTML = `<!DOCTYPE html><meta charset="utf-8"><script src="script.js"></script>`; + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-page-event", "start-background-page"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +async function test(what, background, script) { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["script.js"], + }, + ], + }, + + files: { + "page.html": PAGE_HTML, + "script.js": script, + }, + + background, + }); + + info(`Set up ${what} listener`); + await extension.startup(); + await extension.awaitMessage("bg-ran"); + + info(`Test wakeup for ${what} from an extension page`); + await promiseRestartManager(); + await extension.awaitStartup(); + + function awaitBgEvent() { + return new Promise(resolve => + extension.extension.once("background-page-event", resolve) + ); + } + + let events = trackEvents(extension); + + let url = extension.extension.baseURI.resolve("page.html"); + + let [, page] = await Promise.all([ + awaitBgEvent(), + ExtensionTestUtils.loadContentPage(url, { extension }), + ]); + + equal( + events.get("background-page-event"), + true, + "Should have gotten a background page event" + ); + equal( + events.get("start-background-page"), + false, + "Background page should not be started" + ); + + equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message"); + + let promise = extension.awaitMessage("bg-ran"); + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await promise; + + equal( + events.get("start-background-page"), + true, + "Should have gotten start-background-page event" + ); + + await extension.awaitFinish("messaging-test"); + ok(true, "Background page loaded and received message from extension page"); + + await page.close(); + + info(`Test wakeup for ${what} from a content script`); + ExtensionParent._resetStartupPromises(); + await promiseRestartManager(); + await extension.awaitStartup(); + + events = trackEvents(extension); + + [, page] = await Promise.all([ + awaitBgEvent(), + ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ), + ]); + + equal( + events.get("background-page-event"), + true, + "Should have gotten a background page event" + ); + equal( + events.get("start-background-page"), + false, + "Background page should not be started" + ); + + equal(extension.messageQueue.size, 0, "Have not yet received bg-ran message"); + + promise = extension.awaitMessage("bg-ran"); + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await promise; + + equal( + events.get("start-background-page"), + true, + "Should have gotten start-background-page event" + ); + + await extension.awaitFinish("messaging-test"); + ok(true, "Background page loaded and received message from content script"); + + await page.close(); + await extension.unload(); + + await promiseShutdownManager(); + ExtensionParent._resetStartupPromises(); +} + +add_task(function test_onMessage() { + function script() { + browser.runtime.sendMessage("ping").then(reply => { + browser.test.assertEq( + reply, + "pong", + "Extension page received pong reply" + ); + browser.test.notifyPass("messaging-test"); + }); + } + + async function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq( + msg, + "ping", + "Background page received ping message" + ); + return Promise.resolve("pong"); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + } + + return test("onMessage", background, script); +}); + +add_task(function test_onConnect() { + function script() { + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "pong", "Extension page received pong reply"); + browser.test.notifyPass("messaging-test"); + }); + port.postMessage("ping"); + } + + async function background() { + browser.runtime.onConnect.addListener(port => { + port.onMessage.addListener(msg => { + browser.test.assertEq( + msg, + "ping", + "Background page received ping message" + ); + port.postMessage("pong"); + }); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + } + + return test("onConnect", background, script); +}); + +// Test that messaging works if the background page is started before +// any messages are exchanged. (See bug 1467136 for an example of how +// this broke at one point). +add_task(async function test_other_startup() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + + async background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.notifyPass("startup"); + }); + + // addListener() returns right away but make a round trip to the + // main process to ensure the persistent onMessage listener is recorded. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("bg-ran"); + }, + + files: { + "page.html": PAGE_HTML, + "script.js"() { + browser.runtime.sendMessage("ping"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ran"); + + await promiseRestartManager(); + await extension.awaitStartup(); + + // Start the background page. No message have been sent at this point. + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + await extension.awaitMessage("bg-ran"); + + // Now that the background page is fully started, load a new page that + // sends a message to the background page. + let url = extension.extension.baseURI.resolve("page.html"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + + await extension.awaitFinish("startup"); + + await page.close(); + await extension.unload(); + + await promiseShutdownManager(); + ExtensionParent._resetStartupPromises(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js new file mode 100644 index 0000000000..f71001a74d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging.js @@ -0,0 +1,685 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals chrome */ + +const PREF_MAX_READ = "webextensions.native-messaging.max-input-message-bytes"; +const PREF_MAX_WRITE = + "webextensions.native-messaging.max-output-message-bytes"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +const ECHO_BODY = String.raw` + import struct + import sys + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const INFO_BODY = String.raw` + import json + import os + import struct + import sys + + msg = json.dumps({"args": sys.argv, "cwd": os.getcwd()}) + if sys.version_info >= (3,): + sys.stdout.buffer.write(struct.pack('@I', len(msg))) + else: + sys.stdout.write(struct.pack('@I', len(msg))) + sys.stdout.write(msg) + sys.exit(0) +`; + +const STDERR_LINES = ["hello stderr", "this should be a separate line"]; +let STDERR_MSG = STDERR_LINES.join("\\n"); + +const STDERR_BODY = String.raw` + import sys + sys.stderr.write("${STDERR_MSG}") +`; + +let SCRIPTS = [ + { + name: "echo", + description: "a native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "info", + description: "a native app that gives some info about how it was started", + script: INFO_BODY.replace(/^ {2}/gm, ""), + }, + { + name: "stderr", + description: "a native app that writes to stderr and then exits", + script: STDERR_BODY.replace(/^ {2}/gm, ""), + }, +]; + +if (AppConstants.platform == "win") { + SCRIPTS.push({ + name: "echocmd", + description: "echo but using a .cmd file", + scriptExtension: "cmd", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }); +} + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +// Test the basic operation of native messaging with a simple +// script that echoes back whatever message is sent to it. +add_task(async function test_happy_path() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("message", msg); + }); + browser.test.onMessage.addListener((what, payload) => { + if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + const tests = [ + { + data: "this is a string", + what: "simple string", + }, + { + data: "Это юникода", + what: "unicode string", + }, + { + data: { test: "hello" }, + what: "simple object", + }, + { + data: { + what: "An object with a few properties", + number: 123, + bool: true, + nested: { what: "another object" }, + }, + what: "object with several properties", + }, + + { + data: { + ignoreme: true, + _json: { data: "i have a tojson method" }, + }, + expected: { data: "i have a tojson method" }, + what: "object with toJSON() method", + }, + ]; + for (let test of tests) { + extension.sendMessage("send", test.data); + let response = await extension.awaitMessage("message"); + let expected = test.expected || test.data; + deepEqual(response, expected, `Echoed a message of type ${test.what}`); + } + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +// Just test that the given app (which should be the echo script above) +// can be started. Used to test corner cases in how the native application +// is located/launched. +async function simpleTest(app) { + function background(appname) { + let port = browser.runtime.connectNative(appname); + let MSG = "test"; + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + browser.test.sendMessage("done"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${JSON.stringify(app)});`, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is still running"); + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +} + +if (AppConstants.platform == "win") { + // "relative.echo" has a relative path in the host manifest. + add_task(function test_relative_path() { + return simpleTest("relative.echo"); + }); + + // "echocmd" uses a .cmd file instead of a .bat file + add_task(function test_cmd_file() { + return simpleTest("echocmd"); + }); +} + +// Test sendNativeMessage() +add_task(async function test_sendNativeMessage() { + async function background() { + let MSG = { test: "hello world" }; + + // Check error handling + await browser.test.assertRejects( + browser.runtime.sendNativeMessage("nonexistent", MSG), + /Attempt to postMessage on disconnected port/, + "sendNativeMessage() to a nonexistent app failed" + ); + + // Check regular message exchange + let reply = await browser.runtime.sendNativeMessage("echo", MSG); + + let expected = JSON.stringify(MSG); + let received = JSON.stringify(reply); + browser.test.assertEq(expected, received, "Received echoed native message"); + + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + + // With sendNativeMessage(), the subprocess should be disconnected + // after exchanging a single message. + await waitForSubprocessExit(); + + await extension.unload(); +}); + +// Test calling Port.disconnect() +add_task(async function test_disconnect() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onMessage.addListener((msg, msgPort) => { + browser.test.assertEq( + port, + msgPort, + "onMessage handler should receive the port as the second argument" + ); + browser.test.sendMessage("message", msg); + }); + port.onDisconnect.addListener(msgPort => { + browser.test.fail("onDisconnect should not be called for disconnect()"); + }); + browser.test.onMessage.addListener((what, payload) => { + if (what == "send") { + if (payload._json) { + let json = payload._json; + payload.toJSON = () => json; + delete payload._json; + } + port.postMessage(payload); + } else if (what == "disconnect") { + try { + port.disconnect(); + browser.test.assertThrows( + () => port.postMessage("void"), + "Attempt to postMessage on disconnected port" + ); + browser.test.sendMessage("disconnect-result", { success: true }); + } catch (err) { + browser.test.sendMessage("disconnect-result", { + success: false, + errmsg: err.message, + }); + } + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("send", "test"); + let response = await extension.awaitMessage("message"); + equal(response, "test", "Echoed a string"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "disconnect succeeded"); + + info("waiting for subprocess to exit"); + await waitForSubprocessExit(); + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess is no longer running"); + + extension.sendMessage("disconnect"); + response = await extension.awaitMessage("disconnect-result"); + equal(response.success, true, "second call to disconnect silently ignored"); + + await extension.unload(); +}); + +// Test the limit on message size for writing +add_task(async function test_write_limit() { + Services.prefs.setIntPref(PREF_MAX_WRITE, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_WRITE); + } + registerCleanupFunction(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + try { + port.postMessage(PAYLOAD); + browser.test.sendMessage("result", null); + } catch (ex) { + browser.test.sendMessage("result", ex.message); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let errmsg = await extension.awaitMessage("result"); + notEqual( + errmsg, + null, + "native postMessage() failed for overly large message" + ); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test the limit on message size for reading +add_task(async function test_read_limit() { + Services.prefs.setIntPref(PREF_MAX_READ, 10); + function clearPref() { + Services.prefs.clearUserPref(PREF_MAX_READ); + } + registerCleanupFunction(clearPref); + + function background() { + const PAYLOAD = "0123456789A"; + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "Native application tried to send a message of 13 bytes, which exceeds the limit of 10 bytes.", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage(PAYLOAD); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let result = await extension.awaitMessage("result"); + equal( + result, + "disconnected", + "native port disconnected on receiving large message" + ); + + await extension.unload(); + await waitForSubprocessExit(); + + clearPref(); +}); + +// Test that an extension without the nativeMessaging permission cannot +// use native messaging. +add_task(async function test_ext_permission() { + function background() { + browser.test.assertEq( + chrome.runtime.connectNative, + undefined, + "chrome.runtime.connectNative does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + browser.runtime.connectNative, + undefined, + "browser.runtime.connectNative does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + chrome.runtime.sendNativeMessage, + undefined, + "chrome.runtime.sendNativeMessage does not exist without nativeMessaging permission" + ); + browser.test.assertEq( + browser.runtime.sendNativeMessage, + undefined, + "browser.runtime.sendNativeMessage does not exist without nativeMessaging permission" + ); + browser.test.sendMessage("finished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: {}, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); +}); + +// Test that an extension that is not listed in allowed_extensions for +// a native application cannot use that application. +add_task(async function test_app_permission() { + function background() { + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "No such native application echo", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage({ test: "test" }); + } + + let extension = ExtensionTestUtils.loadExtension( + { + background, + manifest: { + permissions: ["nativeMessaging"], + }, + }, + "somethingelse@tests.mozilla.org" + ); + + await extension.startup(); + + let result = await extension.awaitMessage("result"); + equal( + result, + "disconnected", + "connectNative() failed without native app permission" + ); + + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); +}); + +// Test that the command-line arguments and working directory for the +// native application are as expected. +add_task(async function test_child_process() { + function background() { + let port = browser.runtime.connectNative("info"); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", msg); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let msg = await extension.awaitMessage("result"); + equal(msg.args.length, 3, "Received two command line arguments"); + equal( + msg.args[1], + getPath("info.json"), + "Command line argument is the path to the native host manifest" + ); + equal( + msg.args[2], + ID, + "Second command line argument is the ID of the calling extension" + ); + equal( + msg.cwd.replace(/^\/private\//, "/"), + OS.Path.join(tmpDir.path, TYPE_SLUG), + "Working directory is the directory containing the native appliation" + ); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; +}); + +add_task(async function test_stderr() { + function background() { + let port = browser.runtime.connectNative("stderr"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + null, + port.error, + "Normal application exit is not an error" + ); + browser.test.sendMessage("finished"); + }); + } + + let { messages } = await promiseConsoleOutput(async function() { + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("finished"); + await extension.unload(); + + await waitForSubprocessExit(); + }); + + let lines = STDERR_LINES.map(line => + messages.findIndex(msg => msg.message.includes(line)) + ); + notEqual(lines[0], -1, "Saw first line of stderr output on the console"); + notEqual(lines[1], -1, "Saw second line of stderr output on the console"); + notEqual( + lines[0], + lines[1], + "Stderr output lines are separated in the console" + ); +}); + +// Test that calling connectNative() multiple times works +// (see bug 1313980 for a previous regression in this area) +add_task(async function test_multiple_connects() { + async function background() { + function once() { + return new Promise(resolve => { + let MSG = "hello"; + let port = browser.runtime.connectNative("echo"); + + port.onMessage.addListener(msg => { + browser.test.assertEq(MSG, msg, "Got expected message back"); + port.disconnect(); + resolve(); + }); + port.postMessage(MSG); + }); + } + + await once(); + await once(); + browser.test.notifyPass("multiple-connect"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("multiple-connect"); + await extension.unload(); +}); + +// Test that native messaging is always rejected on content scripts +add_task(async function test_connect_native_from_content_script() { + async function testScript() { + let port = browser.runtime.connectNative("echo"); + port.onDisconnect.addListener(msgPort => { + browser.test.assertEq( + port, + msgPort, + "onDisconnect handler should receive the port as the first argument" + ); + browser.test.assertEq( + "An unexpected error occurred", + port.error && port.error.message + ); + browser.test.sendMessage("result", "disconnected"); + }); + port.onMessage.addListener(msg => { + browser.test.sendMessage("result", "message"); + }); + port.postMessage({ test: "test" }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + run_at: "document_end", + js: ["test.js"], + matches: ["http://example.com/dummy"], + }, + ], + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + files: { + "test.js": testScript, + }, + }); + + await extension.startup(); + + const page = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + let result = await extension.awaitMessage("result"); + equal(result, "disconnected", "connectNative() failed from content script"); + + await page.close(); + await extension.unload(); + + let procCount = await getSubprocessCount(); + equal(procCount, 0, "No child process was started"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js new file mode 100644 index 0000000000..073c83bfd4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_perf.js @@ -0,0 +1,130 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const MAX_ROUND_TRIP_TIME_MS = + AppConstants.DEBUG || AppConstants.ASAN ? 60 : 30; +const MAX_RETRIES = 5; + +const ECHO_BODY = String.raw` + import struct + import sys + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + sys.exit(0) + + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const SCRIPTS = [ + { + name: "echo", + description: "A native app that echoes back messages it receives", + script: ECHO_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +add_task(async function test_round_trip_perf() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(msg => { + if (msg != "run-tests") { + return; + } + + let port = browser.runtime.connectNative("echo"); + + function next() { + port.postMessage({ + Lorem: { + ipsum: { + dolor: [ + "sit amet", + "consectetur adipiscing elit", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + ], + "Ut enim": [ + "ad minim veniam", + "quis nostrud exercitation ullamco", + "laboris nisi ut aliquip ex ea commodo consequat.", + ], + Duis: [ + "aute irure dolor in reprehenderit in", + "voluptate velit esse cillum dolore eu", + "fugiat nulla pariatur.", + ], + Excepteur: [ + "sint occaecat cupidatat non proident", + "sunt in culpa qui officia deserunt", + "mollit anim id est laborum.", + ], + }, + }, + }); + } + + const COUNT = 1000; + let now; + function finish() { + let roundTripTime = (Date.now() - now) / COUNT; + + port.disconnect(); + browser.test.sendMessage("result", roundTripTime); + } + + let count = 0; + port.onMessage.addListener(() => { + if (count == 0) { + // Skip the first round, since it includes the time it takes + // the app to start up. + now = Date.now(); + } + + if (count++ <= COUNT) { + next(); + } else { + finish(); + } + }); + + next(); + }); + }, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + + let roundTripTime = Infinity; + for ( + let i = 0; + i < MAX_RETRIES && roundTripTime > MAX_ROUND_TRIP_TIME_MS; + i++ + ) { + extension.sendMessage("run-tests"); + roundTripTime = await extension.awaitMessage("result"); + } + + await extension.unload(); + + ok( + roundTripTime <= MAX_ROUND_TRIP_TIME_MS, + `Expected round trip time (${roundTripTime}ms) to be less than ${MAX_ROUND_TRIP_TIME_MS}ms` + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js new file mode 100644 index 0000000000..0de24c0c6e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_native_messaging_unresponsive.js @@ -0,0 +1,85 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const WONTDIE_BODY = String.raw` + import signal + import struct + import sys + import time + + signal.signal(signal.SIGTERM, signal.SIG_IGN) + + stdin = getattr(sys.stdin, 'buffer', sys.stdin) + stdout = getattr(sys.stdout, 'buffer', sys.stdout) + + def spin(): + while True: + try: + signal.pause() + except AttributeError: + time.sleep(5) + + while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + spin() + + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + +const SCRIPTS = [ + { + name: "wontdie", + description: + "a native app that does not exit when stdin closes or on SIGTERM", + script: WONTDIE_BODY.replace(/^ {2}/gm, ""), + }, +]; + +add_task(async function setup() { + await setupHosts(SCRIPTS); +}); + +// Test that an unresponsive native application still gets killed eventually +add_task(async function test_unresponsive_native_app() { + // XXX expose GRACEFUL_SHUTDOWN_TIME as a pref and reduce it + // just for this test? + + function background() { + let port = browser.runtime.connectNative("wontdie"); + + const MSG = "echo me"; + // bounce a message to make sure the process actually starts + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, MSG, "Received echoed message"); + browser.test.sendMessage("ready"); + }); + port.postMessage(MSG); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: ID } }, + permissions: ["nativeMessaging"], + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let procCount = await getSubprocessCount(); + equal(procCount, 1, "subprocess is running"); + + let exitPromise = waitForSubprocessExit(); + await extension.unload(); + await exitPromise; + + procCount = await getSubprocessCount(); + equal(procCount, 0, "subprocess was successfully killed"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js new file mode 100644 index 0000000000..758bf48d0b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_networkStatus.js @@ -0,0 +1,190 @@ +"use strict"; + +const Cm = Components.manager; + +const uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService( + Ci.nsIUUIDGenerator +); + +var mockNetworkStatusService = { + contractId: "@mozilla.org/network/network-link-service;1", + + _mockClassId: uuidGenerator.generateUUID(), + + _originalClassId: "", + + QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]), + + createInstance(outer, iiD) { + if (outer) { + throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION); + } + return this.QueryInterface(iiD); + }, + + register() { + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + if (!registrar.isCIDRegistered(this._mockClassId)) { + this._originalClassId = registrar.contractIDToCID(this.contractId); + registrar.registerFactory( + this._mockClassId, + "Unregister after testing", + this.contractId, + this + ); + } + }, + + unregister() { + let registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(this._mockClassId, this); + registrar.registerFactory(this._originalClassId, "", this.contractId, null); + }, + + _isLinkUp: true, + _linkStatusKnown: false, + _linkType: Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN, + + get isLinkUp() { + return this._isLinkUp; + }, + + get linkStatusKnown() { + return this._linkStatusKnown; + }, + + setLinkStatus(status) { + switch (status) { + case "up": + this._isLinkUp = true; + this._linkStatusKnown = true; + this._networkID = "foo"; + break; + case "down": + this._isLinkUp = false; + this._linkStatusKnown = true; + this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN; + this._networkID = undefined; + break; + case "changed": + this._linkStatusKnown = true; + this._networkID = "foo"; + break; + case "unknown": + this._linkStatusKnown = false; + this._linkType = Ci.nsINetworkLinkService.LINK_TYPE_UNKNOWN; + this._networkID = undefined; + break; + } + Services.obs.notifyObservers(null, "network:link-status-changed", status); + }, + + get linkType() { + return this._linkType; + }, + + setLinkType(val) { + this._linkType = val; + this._linkStatusKnown = true; + this._isLinkUp = true; + this._networkID = "bar"; + Services.obs.notifyObservers( + null, + "network:link-type-changed", + this._linkType + ); + }, + + get networkID() { + return this._networkID; + }, +}; + +// nsINetworkLinkService is not directly testable. With the mock service above, +// we just exercise a couple small things here to validate the api works somewhat. +add_task(async function test_networkStatus() { + mockNetworkStatusService.register(); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "networkstatus@tests.mozilla.org" } }, + permissions: ["networkStatus"], + }, + isPrivileged: true, + async background() { + browser.networkStatus.onConnectionChanged.addListener(async details => { + browser.test.log(`connection status ${JSON.stringify(details)}`); + browser.test.sendMessage("connect-changed", { + details, + linkInfo: await browser.networkStatus.getLinkInfo(), + }); + }); + browser.test.sendMessage( + "linkdata", + await browser.networkStatus.getLinkInfo() + ); + }, + }); + + async function test(expected, change) { + if (change.status) { + info(`test link change status to ${change.status}`); + mockNetworkStatusService.setLinkStatus(change.status); + } else if (change.link) { + info(`test link change type to ${change.link}`); + mockNetworkStatusService.setLinkType(change.link); + } + let { details, linkInfo } = await extension.awaitMessage("connect-changed"); + equal(details.type, expected.type, "network type is correct"); + equal(details.status, expected.status, `network status is correct`); + equal(details.id, expected.id, "network id"); + Assert.deepEqual( + linkInfo, + details, + "getLinkInfo should resolve to the same details received from onConnectionChanged" + ); + } + + await extension.startup(); + + let data = await extension.awaitMessage("linkdata"); + equal(data.type, "unknown", "network type is unknown"); + equal(data.status, "unknown", `network status is ${data.status}`); + equal(data.id, undefined, "network id"); + + await test( + { type: "unknown", status: "up", id: "foo" }, + { status: "changed" } + ); + + await test( + { type: "wifi", status: "up", id: "bar" }, + { link: Ci.nsINetworkLinkService.LINK_TYPE_WIFI } + ); + + await test({ type: "unknown", status: "down" }, { status: "down" }); + + await test({ type: "unknown", status: "unknown" }, { status: "unknown" }); + + await extension.unload(); + mockNetworkStatusService.unregister(); +}); + +add_task(async function test_networkStatus_permission() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { id: "networkstatus-permission@tests.mozilla.org" }, + }, + permissions: ["networkStatus"], + }, + async background() { + browser.test.assertEq( + undefined, + browser.networkStatus, + "networkStatus is privileged" + ); + }, + }); + await extension.startup(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js new file mode 100644 index 0000000000..400d60c4a1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_incognito.js @@ -0,0 +1,108 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + +const createdAlerts = []; + +const mockAlertsService = { + showPersistentNotification(persistentData, alert, alertListener) { + this.showAlert(alert, alertListener); + }, + + showAlert(alert, listener) { + createdAlerts.push(alert); + listener.observe(null, "alertfinished", alert.cookie); + }, + + showAlertNotification( + imageUrl, + title, + text, + textClickable, + cookie, + alertListener, + name, + dir, + lang, + data, + principal, + privateBrowsing + ) { + this.showAlert({ cookie, title, text, privateBrowsing }, alertListener); + }, + + closeAlert(name) { + // This mock immediately close the alert on show, so this is empty. + }, + + QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]), + + createInstance(outer, iid) { + if (outer != null) { + throw Components.Exception("", Cr.NS_ERROR_NO_AGGREGATION); + } + return this.QueryInterface(iid); + }, +}; + +const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +registrar.registerFactory( + Components.ID("{173a036a-d678-4415-9cff-0baff6bfe554}"), + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService +); + +add_task(async function test_notification_privateBrowsing_flag() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["notifications"], + }, + files: { + "page.html": `<meta charset="utf-8"><script src="page.js"></script>`, + async "page.js"() { + let closedPromise = new Promise(resolve => { + browser.notifications.onClosed.addListener(resolve); + }); + let createdId = await browser.notifications.create("notifid", { + type: "basic", + title: "titl", + message: "msg", + }); + let closedId = await closedPromise; + browser.test.assertEq(createdId, closedId, "ID of closed notification"); + browser.test.assertEq( + "{}", + JSON.stringify(await browser.notifications.getAll()), + "no notifications left" + ); + browser.test.sendMessage("notification_closed"); + }, + }, + }); + await extension.startup(); + + async function checkPrivateBrowsingFlag(privateBrowsing) { + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/page.html`, + { extension, remote: extension.extension.remote, privateBrowsing } + ); + await extension.awaitMessage("notification_closed"); + await contentPage.close(); + + Assert.equal(createdAlerts.length, 1, "expected one alert"); + let notification = createdAlerts.shift(); + Assert.equal(notification.cookie, "notifid", "notification id"); + Assert.equal(notification.title, "titl", "notification title"); + Assert.equal(notification.text, "msg", "notification text"); + Assert.equal(notification.privateBrowsing, privateBrowsing, "pbm flag"); + } + + await checkPrivateBrowsingFlag(false); + await checkPrivateBrowsingFlag(true); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js new file mode 100644 index 0000000000..1213ae4f23 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_notifications_unsupported.js @@ -0,0 +1,41 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; +const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); +registrar.registerFactory( + Components.ID("{18f25bb4-ab12-4e24-b3b0-69215056160b}"), + "unsupported alerts service", + ALERTS_SERVICE_CONTRACT_ID, + {} // This object lacks an implementation of nsIAlertsService. +); + +add_task(async function test_notification_unsupported_backend() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["notifications"], + }, + async background() { + let closedPromise = new Promise(resolve => { + browser.notifications.onClosed.addListener(resolve); + }); + let createdId = await browser.notifications.create("notifid", { + type: "basic", + title: "titl", + message: "msg", + }); + let closedId = await closedPromise; + browser.test.assertEq(createdId, closedId, "ID of closed notification"); + browser.test.assertEq( + "{}", + JSON.stringify(await browser.notifications.getAll()), + "no notifications left" + ); + browser.test.sendMessage("notification_closed"); + }, + }); + await extension.startup(); + await extension.awaitMessage("notification_closed"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js new file mode 100644 index 0000000000..7da12b40aa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_onmessage_removelistener.js @@ -0,0 +1,30 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + function listener() { + browser.test.notifyFail("listener should not be invoked"); + } + + browser.runtime.onMessage.addListener(listener); + browser.runtime.onMessage.removeListener(listener); + browser.runtime.sendMessage("hello"); + + // Make sure that, if we somehow fail to remove the listener, then we'll run + // the listener before the test is marked as passing. + setTimeout(function() { + browser.test.notifyPass("onmessage_removelistener"); + }, 0); +} + +let extensionData = { + background: backgroundScript, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("onmessage_removelistener"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js new file mode 100644 index 0000000000..7e1370d00f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_performance_counters.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +const ENABLE_COUNTER_PREF = + "extensions.webextensions.enablePerformanceCounters"; +const TIMING_MAX_AGE = "extensions.webextensions.performanceCountersMaxAge"; + +let { ParentAPIManager } = ExtensionParent; + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); // eslint-disable-line mozilla/no-arbitrary-setTimeout +} + +async function retrieveSpecificCounter(apiName, expectedCount) { + let currentCount = 0; + let data; + while (currentCount < expectedCount) { + data = await ParentAPIManager.retrievePerformanceCounters(); + for (let [console, counters] of data) { + for (let [api, counter] of counters) { + if (api == apiName) { + currentCount += counter.calls; + } + } + } + await sleep(100); + } + return data; +} + +async function test_counter() { + async function background() { + // creating a bookmark is done in the parent + let folder = await browser.bookmarks.create({ title: "Folder" }); + await browser.bookmarks.create({ + title: "Bookmark", + url: "http://example.com", + parentId: folder.id, + }); + + // getURL() is done in the child, let do three + browser.extension.getURL("beasts/frog.html"); + browser.extension.getURL("beasts/frog2.html"); + browser.extension.getURL("beasts/frog3.html"); + browser.test.sendMessage("done"); + } + + let extensionData = { + background, + manifest: { + permissions: ["bookmarks"], + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("done"); + + let counters = await retrieveSpecificCounter("getURL", 3); + await extension.unload(); + + // check that the bookmarks.create API was tracked + let counter = counters.get(extension.id).get("bookmarks.create"); + ok(counter.calls > 0); + ok(counter.duration > 0); + + // check that the getURL API was tracked + counter = counters.get(extension.id).get("getURL"); + ok(counter.calls > 0); + ok(counter.duration > 0); +} + +add_task(function test_performance_counter() { + return runWithPrefs( + [ + [ENABLE_COUNTER_PREF, true], + [TIMING_MAX_AGE, 1], + ], + test_counter + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js new file mode 100644 index 0000000000..cc15e28200 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_warnings.js @@ -0,0 +1,654 @@ +"use strict"; + +let { ExtensionTestCommon } = ChromeUtils.import( + "resource://testing-common/ExtensionTestCommon.jsm" +); + +let bundle; +if (AppConstants.MOZ_APP_NAME == "thunderbird") { + bundle = Services.strings.createBundle( + "chrome://messenger/locale/addons.properties" + ); +} else { + bundle = Services.strings.createBundle( + "chrome://browser/locale/browser.properties" + ); +} +const DUMMY_APP_NAME = "Dummy brandName"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.usePrivilegedSignatures = id => id.startsWith("privileged"); +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +async function getManifestPermissions(extensionData) { + let extension = ExtensionTestCommon.generate(extensionData); + // Some tests contain invalid permissions; ignore the warnings about their invalidity. + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.loadManifest(); + ExtensionTestUtils.failOnSchemaWarnings(true); + const { manifestPermissions } = extension; + await extension.cleanupGeneratedFile(); + return manifestPermissions; +} + +function getPermissionWarnings(manifestPermissions, options) { + let info = { + permissions: manifestPermissions, + appName: DUMMY_APP_NAME, + }; + let { msgs } = ExtensionData.formatPermissionStrings(info, bundle, options); + return msgs; +} + +async function getPermissionWarningsForUpdate( + oldExtensionData, + newExtensionData +) { + let oldPerms = await getManifestPermissions(oldExtensionData); + let newPerms = await getManifestPermissions(newExtensionData); + let difference = Extension.comparePermissions(oldPerms, newPerms); + return getPermissionWarnings(difference); +} + +// Tests that the expected permission warnings are generated for various +// combinations of host permissions. +add_task(async function host_permissions() { + let { PluralForm } = ChromeUtils.import( + "resource://gre/modules/PluralForm.jsm" + ); + + let permissionTestCases = [ + { + description: "Empty manifest without permissions", + manifest: {}, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "Invalid match patterns", + manifest: { + permissions: [ + "https:///", + "https://", + "https://*", + "about:ugh", + "about:*", + "about://*/", + "resource://*/", + ], + }, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "moz-extension: permissions", + manifest: { + permissions: ["moz-extension://*/*", "moz-extension://uuid/"], + }, + // moz-extension:-origin does not appear in the permission list, + // but it is implicitly granted anyway. + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "*. host permission", + manifest: { + // This permission is rejected by the manifest and ignored. + permissions: ["http://*./"], + }, + expectedOrigins: [], + expectedWarnings: [], + }, + { + description: "<all_urls> permission", + manifest: { + permissions: ["<all_urls>"], + }, + expectedOrigins: ["<all_urls>"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "file: permissions", + manifest: { + permissions: ["file://*/"], + }, + expectedOrigins: ["file://*/"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "http: permission", + manifest: { + permissions: ["http://*/"], + }, + expectedOrigins: ["http://*/"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "*://*/ permission", + manifest: { + permissions: ["*://*/"], + }, + expectedOrigins: ["*://*/"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "content_script[*].matches", + manifest: { + content_scripts: [ + { + // This test uses the manifest file without loading the content script + // file, so we can use a non-existing dummy file. + js: ["dummy.js"], + matches: ["https://*/"], + }, + ], + }, + expectedOrigins: ["https://*/"], + expectedWarnings: [ + bundle.GetStringFromName("webextPerms.hostDescription.allUrls"), + ], + }, + { + description: "A few host permissions", + manifest: { + permissions: ["http://a/", "http://*.b/", "http://c/*"], + }, + expectedOrigins: ["http://a/", "http://*.b/", "http://c/*"], + expectedWarnings: [ + // Wildcard hosts take precedence in the permission list. + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "b", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "a", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "c", + ]), + ], + }, + { + description: "many host permission", + manifest: { + permissions: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + ], + }, + expectedOrigins: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + ], + expectedWarnings: [ + // Wildcard hosts take precedence in the permission list. + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "1", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "2", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "3", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "4", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "a", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "b", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "c", + ]), + PluralForm.get( + 2, + bundle.GetStringFromName("webextPerms.hostDescription.tooManySites") + ).replace("#1", "2"), + ], + options: { + collapseOrigins: true, + }, + }, + { + description: + "many host permissions without item limit in the warning list", + manifest: { + permissions: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + "http://*.5/", + ], + }, + expectedOrigins: [ + "http://a/", + "http://b/", + "http://c/", + "http://d/", + "http://e/*", + "http://*.1/", + "http://*.2/", + "http://*.3/", + "http://*.4/", + "http://*.5/", + ], + expectedWarnings: [ + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "1", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "2", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "3", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "4", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "5", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "a", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "b", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "c", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "d", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", [ + "e", + ]), + ], + }, + ]; + for (let { + description, + manifest, + expectedOrigins, + expectedWarnings, + options, + } of permissionTestCases) { + let manifestPermissions = await getManifestPermissions({ + manifest, + }); + + deepEqual( + manifestPermissions.origins, + expectedOrigins, + `Expected origins (${description})` + ); + deepEqual( + manifestPermissions.permissions, + [], + `Expected no non-host permissions (${description})` + ); + + let warnings = getPermissionWarnings(manifestPermissions, options); + deepEqual(warnings, expectedWarnings, `Expected warnings (${description})`); + } +}); + +// Tests that the expected permission warnings are generated for a mix of host +// permissions and API permissions. +add_task(async function api_permissions() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + permissions: [ + "activeTab", + "webNavigation", + "tabs", + "nativeMessaging", + "http://x/", + "http://*.x/", + "http://*.tld/", + ], + }, + }); + deepEqual( + manifestPermissions, + { + origins: ["http://x/", "http://*.x/", "http://*.tld/"], + permissions: ["activeTab", "webNavigation", "tabs", "nativeMessaging"], + }, + "Expected origins and permissions" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [ + // Host permissions first, with wildcards on top. + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "x", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "tld", + ]), + bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["x"]), + // nativeMessaging permission warning first of all permissions. + bundle.formatStringFromName("webextPerms.description.nativeMessaging", [ + DUMMY_APP_NAME, + ]), + // Other permissions in alphabetical order. + // Note: activeTab has no permission warning string. + bundle.GetStringFromName("webextPerms.description.tabs"), + bundle.GetStringFromName("webextPerms.description.webNavigation"), + ], + "Expected warnings" + ); +}); + +// Tests that the expected permission warnings are generated for a mix of host +// permissions and API permissions, for a privileged extension that uses the +// mozillaAddons permission. +add_task(async function privileged_with_mozillaAddons() { + let manifestPermissions = await getManifestPermissions({ + isPrivileged: true, + manifest: { + permissions: [ + "mozillaAddons", + "mozillaAddons", + "mozillaAddons", + "resource://x/*", + "http://a/", + "about:reader*", + ], + }, + }); + deepEqual( + manifestPermissions, + { + origins: ["resource://x/*", "http://a/", "about:reader*"], + permissions: ["mozillaAddons"], + }, + "Expected origins and permissions for privileged add-on with mozillaAddons" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [bundle.GetStringFromName("webextPerms.hostDescription.allUrls")], + "Expected warnings for privileged add-on with mozillaAddons permission." + ); +}); + +// Similar to the privileged_with_mozillaAddons test, except the test extension +// is unprivileged and not allowed to use the mozillaAddons permission. +add_task(async function unprivileged_with_mozillaAddons() { + let manifestPermissions = await getManifestPermissions({ + manifest: { + permissions: [ + "mozillaAddons", + "mozillaAddons", + "mozillaAddons", + "resource://x/*", + "http://a/", + "about:reader*", + ], + }, + }); + deepEqual( + manifestPermissions, + { + origins: ["http://a/"], + permissions: [], + }, + "Expected origins and permissions for unprivileged add-on with mozillaAddons" + ); + + deepEqual( + getPermissionWarnings(manifestPermissions), + [bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["a"])], + "Expected warnings for unprivileged add-on with mozillaAddons permission." + ); +}); + +// Tests that an update with less permissions has no warning. +add_task(async function update_drop_permission() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["<all_urls>", "https://a/", "http://b/"], + }, + }, + { + manifest: { + permissions: [ + "https://a/", + "http://b/", + "ftp://host_matching_all_urls/", + ], + }, + } + ); + deepEqual( + warnings, + [], + "An update with fewer permissions should not have any warnings" + ); +}); + +// Tests that an update that switches from "*://*/*" to "<all_urls>" does not +// result in additional permission warnings. +add_task(async function update_all_urls_permission() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["*://*/*"], + }, + }, + { + manifest: { + permissions: ["<all_urls>"], + }, + } + ); + deepEqual( + warnings, + [], + "An update from a wildcard host to <all_urls> should not have any warnings" + ); +}); + +// Tests that an update where a new permission whose domain overlaps with +// an existing permission does not result in additional permission warnings. +add_task(async function update_change_permissions() { + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["https://a/", "http://*.b/", "http://c/", "http://f/"], + }, + }, + { + manifest: { + permissions: [ + // (no new warning) Unchanged permission from old extension. + "https://a/", + // (no new warning) Different schemes, host should match "*.b" wildcard. + "ftp://ftp.b/", + "ws://ws.b/", + "wss://wss.b", + "https://https.b/", + "http://http.b/", + "*://*.b/", + "http://b/", + + // (expect warning) Wildcard was added. + "http://*.c/", + // (no new warning) file:-scheme, but host "f" is same as "http://f/". + "file://f/", + // (expect warning) New permission was added. + "proxy", + ], + }, + } + ); + deepEqual( + warnings, + [ + bundle.formatStringFromName("webextPerms.hostDescription.wildcard", [ + "c", + ]), + bundle.formatStringFromName("webextPerms.description.proxy", [ + DUMMY_APP_NAME, + ]), + ], + "Expected permission warnings for new permissions only" + ); +}); + +// Tests that a privileged extension with the mozillaAddons permission can be +// updated without errors. +add_task(async function update_privileged_with_mozillaAddons() { + let warnings = await getPermissionWarningsForUpdate( + { + isPrivileged: true, + manifest: { + permissions: ["mozillaAddons", "resource://a/"], + }, + }, + { + isPrivileged: true, + manifest: { + permissions: ["mozillaAddons", "resource://a/", "resource://b/"], + }, + } + ); + deepEqual( + warnings, + [bundle.formatStringFromName("webextPerms.hostDescription.oneSite", ["b"])], + "Expected permission warnings for new host only" + ); +}); + +// Tests that an unprivileged extension cannot get privileged permissions +// through an update. +add_task(async function update_unprivileged_with_mozillaAddons() { + // Unprivileged + let warnings = await getPermissionWarningsForUpdate( + { + manifest: { + permissions: ["mozillaAddons", "resource://a/"], + }, + }, + { + manifest: { + permissions: ["mozillaAddons", "resource://a/", "resource://b/"], + }, + } + ); + deepEqual( + warnings, + [], + "resource:-scheme is unsupported for unprivileged extensions" + ); +}); + +// Tests that invalid permission warning for privileged permissions requested +// without the privilged signature are emitted by the Extension class instance +// but not for the ExtensionData instances (on which the signature is not +// available and the warning would be emitted even for the ones signed correctly). +add_task( + async function test_invalid_permission_warning_on_privileged_permission() { + await AddonTestUtils.promiseStartupManager(); + + async function testInvalidPermissionWarning({ isPrivileged }) { + let id = isPrivileged + ? "privileged-addon@mochi.test" + : "nonprivileged-addon@mochi.test"; + + let expectedWarnings = isPrivileged + ? [] + : ["Reading manifest: Invalid extension permission: mozillaAddons"]; + + const ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["mozillaAddons"], + applications: { gecko: { id } }, + }, + background() {}, + }); + + await ext.startup(); + const { warnings } = ext.extension; + Assert.deepEqual( + warnings, + expectedWarnings, + `Got the expected warning for ${id}` + ); + await ext.unload(); + } + + await testInvalidPermissionWarning({ isPrivileged: false }); + await testInvalidPermissionWarning({ isPrivileged: true }); + + info("Test invalid permission warning on ExtensionData instance"); + // Generate an extension (just to be able to reuse its rootURI for the + // ExtensionData instance created below). + let generatedExt = ExtensionTestCommon.generate({ + manifest: { + permissions: ["mozillaAddons"], + applications: { gecko: { id: "extension-data@mochi.test" } }, + }, + }); + + // Verify that XPIInstall.jsm will not collect the warning for the + // privileged permission as expected. + const extData = new ExtensionData(generatedExt.rootURI); + await extData.loadManifest(); + Assert.deepEqual( + extData.warnings, + [], + "No warnings for mozillaAddons permission collected for the ExtensionData instance" + ); + + // This assertion is just meant to prevent the test to pass if there were no warnings + // because some errors prevented the warnings to be collected). + Assert.deepEqual( + extData.errors, + [], + "No errors collected by the ExtensionData instance" + ); + // Cleanup the generated xpi file. + await generatedExt.cleanupGeneratedFile(); + + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js new file mode 100644 index 0000000000..4b9ade044c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permission_xhr.js @@ -0,0 +1,235 @@ +"use strict"; + +const server = createHttpServer({ + hosts: ["xpcshell.test", "example.com", "example.org"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/example.txt", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +/* eslint-disable mozilla/balanced-listeners */ + +add_task(async function test_simple() { + async function runTests(cx) { + function xhr(XMLHttpRequest) { + return url => { + return new Promise((resolve, reject) => { + let req = new XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", resolve); + req.addEventListener("error", reject); + req.send(); + }); + }; + } + + function run(shouldFail, fetch) { + function passListener() { + browser.test.succeed(`${cx}.${fetch.name} pass listener`); + } + + function failListener() { + browser.test.fail(`${cx}.${fetch.name} fail listener`); + } + + /* eslint-disable no-else-return */ + if (shouldFail) { + return fetch("http://example.org/example.txt").then( + failListener, + passListener + ); + } else { + return fetch("http://example.com/example.txt").then( + passListener, + failListener + ); + } + /* eslint-enable no-else-return */ + } + + try { + await run(true, xhr(XMLHttpRequest)); + await run(false, xhr(XMLHttpRequest)); + await run(true, xhr(window.XMLHttpRequest)); + await run(false, xhr(window.XMLHttpRequest)); + await run(true, fetch); + await run(false, fetch); + await run(true, window.fetch); + await run(false, window.fetch); + } catch (err) { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("permission_xhr"); + } + } + + async function background(runTestsFn) { + await runTestsFn("bg"); + browser.test.notifyPass("permission_xhr"); + } + + let extensionData = { + background: `(${background})(${runTests})`, + manifest: { + permissions: ["http://example.com/"], + content_scripts: [ + { + matches: ["http://xpcshell.test/data/file_permission_xhr.html"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": `(${async runTestsFn => { + await runTestsFn("content"); + + window.wrappedJSObject.privilegedFetch = fetch; + window.wrappedJSObject.privilegedXHR = XMLHttpRequest; + + window.addEventListener("message", function rcv({ data }) { + switch (data.msg) { + case "test": + break; + + case "assertTrue": + browser.test.assertTrue(data.condition, data.description); + break; + + case "finish": + window.removeEventListener("message", rcv); + browser.test.sendMessage("content-script-finished"); + break; + } + }); + window.postMessage("test", "*"); + }})(${runTests})`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://xpcshell.test/data/file_permission_xhr.html" + ); + await extension.awaitMessage("content-script-finished"); + await contentPage.close(); + + await extension.awaitFinish("permission_xhr"); + await extension.unload(); +}); + +// This test case ensures that a WebExtension content script can still use the same +// XMLHttpRequest and fetch APIs that the webpage can use and be recognized from +// the target server with the same origin and referer headers of the target webpage +// (see Bug 1295660 for a rationale). +add_task(async function test_page_xhr() { + async function contentScript() { + const content = this.content; + + const { webpageFetchResult, webpageXhrResult } = await new Promise( + resolve => { + const listenPageMessage = event => { + if (!event.data || event.data.type !== "testPageGlobals") { + return; + } + + window.removeEventListener("message", listenPageMessage); + + browser.test.assertEq( + true, + !!content.XMLHttpRequest, + "The content script should have access to content.XMLHTTPRequest" + ); + browser.test.assertEq( + true, + !!content.fetch, + "The content script should have access to window.pageFetch" + ); + + resolve(event.data); + }; + + window.addEventListener("message", listenPageMessage); + + window.postMessage({}, "*"); + } + ); + + const url = new URL("/return_headers.sjs", location).href; + + await Promise.all([ + new Promise((resolve, reject) => { + const req = new content.XMLHttpRequest(); + req.open("GET", url); + req.addEventListener("load", () => + resolve(JSON.parse(req.responseText)) + ); + req.addEventListener("error", reject); + req.send(); + }), + content.fetch(url).then(res => res.json()), + ]) + .then(async ([xhrResult, fetchResult]) => { + browser.test.assertEq( + webpageFetchResult.referer, + fetchResult.referer, + "window.pageFetch referrer is the same of a webpage fetch request" + ); + browser.test.assertEq( + webpageFetchResult.origin, + fetchResult.origin, + "window.pageFetch origin is the same of a webpage fetch request" + ); + + browser.test.assertEq( + webpageXhrResult.referer, + xhrResult.referer, + "content.XMLHttpRequest referrer is the same of a webpage fetch request" + ); + }) + .catch(error => { + browser.test.fail(`Unexpected error: ${error}`); + }); + + browser.test.notifyPass("content-script-page-xhr"); + } + + let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://xpcshell.test/*"], + js: ["content.js"], + }, + ], + }, + files: { + "content.js": `(${contentScript})()`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://xpcshell.test/data/file_page_xhr.html" + ); + await extension.awaitFinish("content-script-page-xhr"); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js new file mode 100644 index 0000000000..385563bab2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions.js @@ -0,0 +1,845 @@ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +// ExtensionParent.jsm is being imported lazily because when it is imported Services.appinfo will be +// retrieved and cached (as a side-effect of Schemas.jsm being imported), and so Services.appinfo +// will not be returning the version set by AddonTestUtils.createAppInfo and this test will +// fail on non-nightly builds (because the cached appinfo.version will be undefined and +// AddonManager startup will fail). +ChromeUtils.defineModuleGetter( + this, + "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm" +); + +const BROWSER_PROPERTIES = "chrome://browser/locale/browser.properties"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +let sawPrompt = false; +let acceptPrompt = false; +const observer = { + observe(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + sawPrompt = true; + let { resolve } = subject.wrappedJSObject; + resolve(acceptPrompt); + } + }, +}; + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + true + ); + Services.obs.addObserver(observer, "webextension-optional-permission-prompt"); + registerCleanupFunction(() => { + Services.obs.removeObserver( + observer, + "webextension-optional-permission-prompt" + ); + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; +}); + +add_task(async function test_permissions_on_startup() { + let extensionId = "@permissionTest"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { id: extensionId }, + }, + permissions: ["tabs"], + }, + useAddonManager: "permanent", + async background() { + let perms = await browser.permissions.getAll(); + browser.test.sendMessage("permissions", perms); + }, + }); + let adding = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }; + await extension.startup(); + let perms = await extension.awaitMessage("permissions"); + equal(perms.permissions.length, 1, "one permission"); + equal(perms.permissions[0], "tabs", "internal permission not present"); + + const { StartupCache } = ExtensionParent; + + // StartupCache.permissions will not contain the extension permissions. + let manifestData = await StartupCache.permissions.get(extensionId, () => { + return { permissions: [], origins: [] }; + }); + equal(manifestData.permissions.length, 0, "no permission"); + + perms = await ExtensionPermissions.get(extensionId); + equal(perms.permissions.length, 0, "no permissions"); + await ExtensionPermissions.add(extensionId, adding); + + // Restart the extension and re-test the permissions. + await ExtensionPermissions._uninit(); + await AddonTestUtils.promiseRestartManager(); + let restarted = extension.awaitMessage("permissions"); + await extension.awaitStartup(); + perms = await restarted; + + manifestData = await StartupCache.permissions.get(extensionId, () => { + return { permissions: [], origins: [] }; + }); + deepEqual( + manifestData.permissions, + adding.permissions, + "StartupCache.permissions contains permission" + ); + + equal(perms.permissions.length, 1, "one permission"); + equal(perms.permissions[0], "tabs", "internal permission not present"); + let added = await ExtensionPermissions._get(extensionId); + deepEqual(added, adding, "permissions were retained"); + + await extension.unload(); +}); + +add_task(async function test_permissions() { + const REQUIRED_PERMISSIONS = ["downloads"]; + const REQUIRED_ORIGINS = ["*://site.com/", "*://*.domain.com/"]; + const REQUIRED_ORIGINS_NORMALIZED = ["*://site.com/*", "*://*.domain.com/*"]; + + const OPTIONAL_PERMISSIONS = ["idle", "clipboardWrite"]; + const OPTIONAL_ORIGINS = [ + "http://optionalsite.com/", + "https://*.optionaldomain.com/", + ]; + const OPTIONAL_ORIGINS_NORMALIZED = [ + "http://optionalsite.com/*", + "https://*.optionaldomain.com/*", + ]; + + function background() { + browser.test.onMessage.addListener(async (method, arg) => { + if (method == "getAll") { + let perms = await browser.permissions.getAll(); + let url = browser.extension.getURL("*"); + perms.origins = perms.origins.filter(i => i != url); + browser.test.sendMessage("getAll.result", perms); + } else if (method == "contains") { + let result = await browser.permissions.contains(arg); + browser.test.sendMessage("contains.result", result); + } else if (method == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", { + status: "success", + result, + }); + } catch (err) { + browser.test.sendMessage("request.result", { + status: "error", + message: err.message, + }); + } + } else if (method == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: [...REQUIRED_PERMISSIONS, ...REQUIRED_ORIGINS], + optional_permissions: [...OPTIONAL_PERMISSIONS, ...OPTIONAL_ORIGINS], + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + function call(method, arg) { + extension.sendMessage(method, arg); + return extension.awaitMessage(`${method}.result`); + } + + let result = await call("getAll"); + deepEqual(result.permissions, REQUIRED_PERMISSIONS); + deepEqual(result.origins, REQUIRED_ORIGINS_NORMALIZED); + + for (let perm of REQUIRED_PERMISSIONS) { + result = await call("contains", { permissions: [perm] }); + equal(result, true, `contains() returns true for fixed permission ${perm}`); + } + for (let origin of REQUIRED_ORIGINS) { + result = await call("contains", { origins: [origin] }); + equal(result, true, `contains() returns true for fixed origin ${origin}`); + } + + // None of the optional permissions should be available yet + for (let perm of OPTIONAL_PERMISSIONS) { + result = await call("contains", { permissions: [perm] }); + equal(result, false, `contains() returns false for permission ${perm}`); + } + for (let origin of OPTIONAL_ORIGINS) { + result = await call("contains", { origins: [origin] }); + equal(result, false, `contains() returns false for origin ${origin}`); + } + + result = await call("contains", { + permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS], + }); + equal( + result, + false, + "contains() returns false for a mix of available and unavailable permissions" + ); + + let perm = OPTIONAL_PERMISSIONS[0]; + result = await call("request", { permissions: [perm] }); + equal( + result.status, + "error", + "request() fails if not called from an event handler" + ); + ok( + /request may only be called from a user input handler/.test(result.message), + "error message for calling request() outside an event handler is reasonable" + ); + result = await call("contains", { permissions: [perm] }); + equal( + result, + false, + "Permission requested outside an event handler was not granted" + ); + + await withHandlingUserInput(extension, async () => { + result = await call("request", { permissions: ["notifications"] }); + equal( + result.status, + "error", + "request() for permission not in optional_permissions should fail" + ); + ok( + /since it was not declared in optional_permissions/.test(result.message), + "error message for undeclared optional_permission is reasonable" + ); + + // Check request() when the prompt is canceled. + acceptPrompt = false; + result = await call("request", { permissions: [perm] }); + equal(result.status, "success", "request() returned cleanly"); + equal( + result.result, + false, + "request() returned false for rejected permission" + ); + + result = await call("contains", { permissions: [perm] }); + equal(result, false, "Rejected permission was not granted"); + + // Call request() and accept the prompt + acceptPrompt = true; + let allOptional = { + permissions: OPTIONAL_PERMISSIONS, + origins: OPTIONAL_ORIGINS, + }; + result = await call("request", allOptional); + equal(result.status, "success", "request() returned cleanly"); + equal( + result.result, + true, + "request() returned true for accepted permissions" + ); + + // Verify that requesting a permission/origin in the wrong field fails + let originsAsPerms = { + permissions: OPTIONAL_ORIGINS, + }; + let permsAsOrigins = { + origins: OPTIONAL_PERMISSIONS, + }; + + result = await call("request", originsAsPerms); + equal( + result.status, + "error", + "Requesting an origin as a permission should fail" + ); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + "Error message for origin as permission is reasonable" + ); + + result = await call("request", permsAsOrigins); + equal( + result.status, + "error", + "Requesting a permission as an origin should fail" + ); + ok( + /Type error for parameter permissions \(Error processing origins/.test( + result.message + ), + "Error message for permission as origin is reasonable" + ); + }); + + let allPermissions = { + permissions: [...REQUIRED_PERMISSIONS, ...OPTIONAL_PERMISSIONS], + origins: [...REQUIRED_ORIGINS_NORMALIZED, ...OPTIONAL_ORIGINS_NORMALIZED], + }; + + result = await call("getAll"); + deepEqual( + result, + allPermissions, + "getAll() returns required and runtime requested permissions" + ); + + result = await call("contains", allPermissions); + equal( + result, + true, + "contains() returns true for runtime requested permissions" + ); + + // Restart, verify permissions are still present + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + result = await call("getAll"); + deepEqual( + result, + allPermissions, + "Runtime requested permissions are still present after restart" + ); + + // Check remove() + result = await call("remove", { permissions: OPTIONAL_PERMISSIONS }); + equal(result, true, "remove() succeeded"); + + let perms = { + permissions: REQUIRED_PERMISSIONS, + origins: [...REQUIRED_ORIGINS_NORMALIZED, ...OPTIONAL_ORIGINS_NORMALIZED], + }; + result = await call("getAll"); + deepEqual(result, perms, "Expected permissions remain after removing some"); + + result = await call("remove", { origins: OPTIONAL_ORIGINS }); + equal(result, true, "remove() succeeded"); + + perms.origins = REQUIRED_ORIGINS_NORMALIZED; + result = await call("getAll"); + deepEqual(result, perms, "Back to default permissions after removing more"); + + await extension.unload(); +}); + +add_task(async function test_startup() { + async function background() { + browser.test.onMessage.addListener(async perms => { + await browser.permissions.request(perms); + browser.test.sendMessage("requested"); + }); + + let all = await browser.permissions.getAll(); + let url = browser.extension.getURL("*"); + all.origins = all.origins.filter(i => i != url); + browser.test.sendMessage("perms", all); + } + + const PERMS1 = { + permissions: ["clipboardRead", "tabs"], + }; + const PERMS2 = { + origins: ["https://site2.com/*"], + }; + + let extension1 = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: PERMS1.permissions, + }, + useAddonManager: "permanent", + }); + let extension2 = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: PERMS2.origins, + }, + useAddonManager: "permanent", + }); + + await extension1.startup(); + await extension2.startup(); + + let perms = await extension1.awaitMessage("perms"); + perms = await extension2.awaitMessage("perms"); + + await withHandlingUserInput(extension1, async () => { + extension1.sendMessage(PERMS1); + await extension1.awaitMessage("requested"); + }); + + await withHandlingUserInput(extension2, async () => { + extension2.sendMessage(PERMS2); + await extension2.awaitMessage("requested"); + }); + + // Restart everything, and force the permissions store to be + // re-read on startup + await ExtensionPermissions._uninit(); + await AddonTestUtils.promiseRestartManager(); + await extension1.awaitStartup(); + await extension2.awaitStartup(); + + async function checkPermissions(extension, permissions) { + perms = await extension.awaitMessage("perms"); + let expect = Object.assign({ permissions: [], origins: [] }, permissions); + deepEqual(perms, expect, "Extension got correct permissions on startup"); + } + + await checkPermissions(extension1, PERMS1); + await checkPermissions(extension2, PERMS2); + + await extension1.unload(); + await extension2.unload(); +}); + +// Test that we don't prompt for permissions an extension already has. +add_task(async function test_alreadyGranted() { + const REQUIRED_PERMISSIONS = [ + "geolocation", + "*://required-host.com/", + "*://*.required-domain.com/", + ]; + const OPTIONAL_PERMISSIONS = [ + ...REQUIRED_PERMISSIONS, + "clipboardRead", + "*://optional-host.com/", + "*://*.optional-domain.com/", + ]; + + function pageScript() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", result); + } else if (msg == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } else if (msg == "close") { + window.close(); + } + }); + + browser.test.sendMessage("page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("ready", browser.runtime.getURL("page.html")); + }, + + manifest: { + permissions: REQUIRED_PERMISSIONS, + optional_permissions: OPTIONAL_PERMISSIONS, + }, + + files: { + "page.html": `<html><head> + <script src="page.js"><\/script> + </head></html>`, + + "page.js": pageScript, + }, + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + let url = await extension.awaitMessage("ready"); + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-ready"); + + async function checkRequest(arg, expectPrompt, msg) { + sawPrompt = false; + extension.sendMessage("request", arg); + let result = await extension.awaitMessage("request.result"); + ok(result, "request() call succeeded"); + equal( + sawPrompt, + expectPrompt, + `Got ${expectPrompt ? "" : "no "}permission prompt for ${msg}` + ); + } + + await checkRequest( + { permissions: ["geolocation"] }, + false, + "required permission from manifest" + ); + await checkRequest( + { origins: ["http://required-host.com/"] }, + false, + "origin permission from manifest" + ); + await checkRequest( + { origins: ["http://host.required-domain.com/"] }, + false, + "wildcard origin permission from manifest" + ); + + await checkRequest( + { permissions: ["clipboardRead"] }, + true, + "optional permission" + ); + await checkRequest( + { permissions: ["clipboardRead"] }, + false, + "already granted optional permission" + ); + + await checkRequest( + { origins: ["http://optional-host.com/"] }, + true, + "optional origin" + ); + await checkRequest( + { origins: ["http://optional-host.com/"] }, + false, + "already granted origin permission" + ); + + await checkRequest( + { origins: ["http://*.optional-domain.com/"] }, + true, + "optional wildcard origin" + ); + await checkRequest( + { origins: ["http://*.optional-domain.com/"] }, + false, + "already granted optional wildcard origin" + ); + await checkRequest( + { origins: ["http://host.optional-domain.com/"] }, + false, + "host matching optional wildcard origin" + ); + await page.close(); + }); + + await extension.unload(); +}); + +// IMPORTANT: Do not change this list without review from a Web Extensions peer! + +const GRANTED_WITHOUT_USER_PROMPT = [ + "activeTab", + "activityLog", + "alarms", + "captivePortal", + "contextMenus", + "contextualIdentities", + "cookies", + "dns", + "geckoProfiler", + "identity", + "idle", + "menus", + "menus.overrideContext", + "mozillaAddons", + "networkStatus", + "normandyAddonStudy", + "search", + "storage", + "telemetry", + "theme", + "unlimitedStorage", + "urlbar", + "webRequest", + "webRequestBlocking", +]; + +add_task(function test_permissions_have_localization_strings() { + let noPromptNames = Schemas.getPermissionNames([ + "PermissionNoPrompt", + "OptionalPermissionNoPrompt", + ]); + Assert.deepEqual( + GRANTED_WITHOUT_USER_PROMPT, + noPromptNames, + "List of no-prompt permissions is correct." + ); + + const bundle = Services.strings.createBundle(BROWSER_PROPERTIES); + + for (const perm of Schemas.getPermissionNames()) { + try { + const str = bundle.GetStringFromName(`webextPerms.description.${perm}`); + + ok(str.length, `Found localization string for '${perm}' permission`); + } catch (e) { + ok( + GRANTED_WITHOUT_USER_PROMPT.includes(perm), + `Permission '${perm}' intentionally granted without prompting the user` + ); + } + } +}); + +// Check <all_urls> used as an optional API permission. +add_task(async function test_optional_all_urls() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + optional_permissions: ["<all_urls>"], + }, + + background() { + browser.test.onMessage.addListener(async () => { + let before = !!browser.tabs.captureVisibleTab; + let granted = await browser.permissions.request({ + origins: ["<all_urls>"], + }); + let after = !!browser.tabs.captureVisibleTab; + + browser.test.sendMessage("results", [before, granted, after]); + }); + }, + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + let [before, granted, after] = await extension.awaitMessage("results"); + + equal( + before, + false, + "captureVisibleTab() unavailable before optional permission request()" + ); + equal(granted, true, "request() for optional permissions granted"); + equal( + after, + true, + "captureVisibleTab() available after optional permission request()" + ); + }); + + await extension.unload(); +}); + +// Check that optional permissions are not included in update prompts +add_task(async function test_permissions_prompt() { + function background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("result", result); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version: 2, + version: "1.0", + + permissions: ["tabs", "https://test1.example.com/*"], + optional_permissions: ["clipboardWrite", "<all_urls>"], + + content_scripts: [ + { + matches: ["https://test2.example.com/*"], + js: [], + }, + ], + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + permissions: ["clipboardWrite"], + origins: ["https://test2.example.com/*"], + }); + let result = await extension.awaitMessage("result"); + equal(result, true, "request() for optional permissions succeeded"); + }); + + const PERMS = ["history", "tabs"]; + const ORIGINS = ["https://test1.example.com/*", "https://test3.example.com/"]; + let xpi = AddonTestUtils.createTempWebExtensionFile({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version: 2, + version: "2.0", + + applications: { gecko: { id: extension.id } }, + + permissions: [...PERMS, ...ORIGINS], + optional_permissions: ["clipboardWrite", "<all_urls>"], + }, + }); + + let install = await AddonManager.getInstallForFile(xpi); + + Services.prefs.setBoolPref("extensions.webextPermissionPrompts", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextPermissionPrompts"); + }); + + let perminfo; + install.promptHandler = info => { + perminfo = info; + return Promise.resolve(); + }; + + await AddonTestUtils.promiseCompleteInstall(install); + await extension.awaitStartup(); + + notEqual(perminfo, undefined, "Permission handler was invoked"); + let perms = perminfo.addon.userPermissions; + deepEqual( + perms.permissions, + PERMS, + "Update details includes only manifest api permissions" + ); + deepEqual( + perms.origins, + ORIGINS, + "Update details includes only manifest origin permissions" + ); + + await extension.unload(); +}); + +// Check that internal permissions can not be set and are not returned by the API. +add_task(async function test_internal_permissions() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + function background() { + browser.test.onMessage.addListener(async (method, arg) => { + try { + if (method == "getAll") { + let perms = await browser.permissions.getAll(); + browser.test.sendMessage("getAll.result", perms); + } else if (method == "contains") { + let result = await browser.permissions.contains(arg); + browser.test.sendMessage("contains.result", { + status: "success", + result, + }); + } else if (method == "request") { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", { + status: "success", + result, + }); + } else if (method == "remove") { + let result = await browser.permissions.remove(arg); + browser.test.sendMessage("remove.result", result); + } + } catch (err) { + browser.test.sendMessage(`${method}.result`, { + status: "error", + message: err.message, + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + name: "permissions test", + description: "permissions test", + manifest_version: 2, + version: "1.0", + permissions: [], + }, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }); + + let perm = "internal:privateBrowsingAllowed"; + + await extension.startup(); + + function call(method, arg) { + extension.sendMessage(method, arg); + return extension.awaitMessage(`${method}.result`); + } + + let result = await call("getAll"); + ok(!result.permissions.includes(perm), "internal not returned"); + + result = await call("contains", { permissions: [perm] }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to check for internal permission: ${result.message}` + ); + + result = await call("remove", { permissions: [perm] }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to remove for internal permission ${result.message}` + ); + + await withHandlingUserInput(extension, async () => { + result = await call("request", { + permissions: [perm], + origins: [], + }); + ok( + /Type error for parameter permissions \(Error processing permissions/.test( + result.message + ), + `Unable to request internal permission ${result.message}` + ); + }); + + await extension.unload(); + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js new file mode 100644 index 0000000000..910aef6df7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_api.js @@ -0,0 +1,397 @@ +"use strict"; + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "ExtensionParent", + "resource://gre/modules/ExtensionParent.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +let OptionalPermissions; + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; + + // We want to get a list of optional permissions prior to loading an extension, + // so we'll get ExtensionParent to do that for us. + await ExtensionParent.apiManager.lazyInit(); + + // These permissions have special behaviors and/or are not mapped directly to an + // api namespace. They will have their own tests for specific behavior. + let ignore = [ + "activeTab", + "clipboardRead", + "clipboardWrite", + "devtools", + "downloads.open", + "geolocation", + "management", + "menus.overrideContext", + "search", + "tabHide", + "tabs", + "webRequestBlocking", + ]; + OptionalPermissions = Schemas.getPermissionNames([ + "OptionalPermission", + "OptionalPermissionNoPrompt", + ]).filter(n => !ignore.includes(n)); +}); + +add_task(async function test_api_on_permissions_changed() { + async function background() { + let manifest = browser.runtime.getManifest(); + let permObj = { permissions: manifest.optional_permissions, origins: [] }; + + function verifyPermissions(enabled) { + for (let perm of manifest.optional_permissions) { + browser.test.assertEq( + enabled, + !!browser[perm], + `${perm} API is ${ + enabled ? "injected" : "removed" + } after permission request` + ); + } + } + + browser.permissions.onAdded.addListener(details => { + browser.test.assertEq( + JSON.stringify(details.permissions), + JSON.stringify(manifest.optional_permissions), + "expected permissions added" + ); + verifyPermissions(true); + browser.test.sendMessage("added"); + }); + + browser.permissions.onRemoved.addListener(details => { + browser.test.assertEq( + JSON.stringify(details.permissions), + JSON.stringify(manifest.optional_permissions), + "expected permissions removed" + ); + verifyPermissions(false); + browser.test.sendMessage("removed"); + }); + + browser.test.onMessage.addListener((msg, enabled) => { + if (msg === "request") { + browser.permissions.request(permObj); + } else if (msg === "verify_access") { + verifyPermissions(enabled); + browser.test.sendMessage("verified"); + } else if (msg === "revoke") { + browser.permissions.remove(permObj); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: OptionalPermissions, + }, + useAddonManager: "permanent", + }); + await extension.startup(); + + function addPermissions() { + extension.sendMessage("request"); + return extension.awaitMessage("added"); + } + + function removePermissions() { + extension.sendMessage("revoke"); + return extension.awaitMessage("removed"); + } + + function verifyPermissions(enabled) { + extension.sendMessage("verify_access", enabled); + return extension.awaitMessage("verified"); + } + + await withHandlingUserInput(extension, async () => { + await addPermissions(); + await removePermissions(); + await addPermissions(); + }); + + // reset handlingUserInput for the restart + extensionHandlers.delete(extension); + + // Verify access on restart + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + await verifyPermissions(true); + + await withHandlingUserInput(extension, async () => { + await removePermissions(); + }); + + // Add private browsing to be sure it doesn't come through. + let permObj = { + permissions: OptionalPermissions.concat("internal:privateBrowsingAllowed"), + origins: [], + }; + + // enable the permissions while the addon is running + await ExtensionPermissions.add(extension.id, permObj, extension.extension); + await extension.awaitMessage("added"); + await verifyPermissions(true); + + // disable the permissions while the addon is running + await ExtensionPermissions.remove(extension.id, permObj, extension.extension); + await extension.awaitMessage("removed"); + await verifyPermissions(false); + + // Add private browsing to test internal permission. If it slips through, + // we would get an error for an additional added message. + await ExtensionPermissions.add( + extension.id, + { permissions: ["internal:privateBrowsingAllowed"], origins: [] }, + extension.extension + ); + + // disable the addon and re-test revoking permissions. + await withHandlingUserInput(extension, async () => { + await addPermissions(); + }); + let addon = await AddonManager.getAddonByID(extension.id); + await addon.disable(); + await ExtensionPermissions.remove(extension.id, permObj); + await addon.enable(); + await extension.awaitStartup(); + + await verifyPermissions(false); + let perms = await ExtensionPermissions.get(extension.id); + equal(perms.permissions.length, 0, "no permissions on startup"); + + await extension.unload(); +}); + +add_task(async function test_geo_permissions() { + async function background() { + const permObj = { permissions: ["geolocation"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + let result = await browser.permissions.contains(permObj); + browser.test.sendMessage("done", result); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { gecko: { id: "geo-test@test" } }, + optional_permissions: ["geolocation"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed on install" + ); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + ok(await extension.awaitMessage("done"), "permission granted"); + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.ALLOW_ACTION, + "geolocation allowed after requested" + ); + + extension.sendMessage("remove"); + ok(!(await extension.awaitMessage("done")), "permission revoked"); + + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed after removed" + ); + + // re-grant to test update removal + extension.sendMessage("request"); + ok(await extension.awaitMessage("done"), "permission granted"); + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.ALLOW_ACTION, + "geolocation allowed after re-requested" + ); + }); + + // We should not have geo permission after this upgrade. + await extension.upgrade({ + manifest: { + applications: { gecko: { id: "geo-test@test" } }, + }, + useAddonManager: "permanent", + }); + + equal( + Services.perms.testPermissionFromPrincipal(principal, "geo"), + Services.perms.UNKNOWN_ACTION, + "geolocation not allowed after upgrade" + ); + + await extension.unload(); +}); + +add_task(async function test_browserSetting_permissions() { + async function background() { + const permObj = { permissions: ["browserSettings"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + await browser.browserSettings.cacheEnabled.set({ value: false }); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + browser.test.sendMessage("done"); + }); + } + + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["browserSettings"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + ok(cacheIsEnabled(), "setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(!cacheIsEnabled(), "setting was set after request"); + + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(cacheIsEnabled(), "setting is reset after remove"); + + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(!cacheIsEnabled(), "setting was set after request"); + }); + + await ExtensionPermissions._uninit(); + extensionHandlers.delete(extension); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(cacheIsEnabled(), "setting is reset after remove"); + }); + + await extension.unload(); +}); + +add_task(async function test_privacy_permissions() { + async function background() { + const permObj = { permissions: ["privacy"] }; + browser.test.onMessage.addListener(async msg => { + if (msg === "request") { + await browser.permissions.request(permObj); + await browser.privacy.websites.trackingProtectionMode.set({ + value: "always", + }); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + } + browser.test.sendMessage("done"); + }); + } + + function hasSetting() { + return Services.prefs.getBoolPref("privacy.trackingprotection.enabled"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["privacy"], + }, + useAddonManager: "permanent", + }); + await extension.startup(); + ok(!hasSetting(), "setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(hasSetting(), "setting was set after request"); + + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(!hasSetting(), "setting is reset after remove"); + + extension.sendMessage("request"); + await extension.awaitMessage("done"); + ok(hasSetting(), "setting was set after request"); + }); + + await ExtensionPermissions._uninit(); + extensionHandlers.delete(extension); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("done"); + ok(!hasSetting(), "setting is reset after remove"); + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js new file mode 100644 index 0000000000..4b9dccf7b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_migrate.js @@ -0,0 +1,252 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + await AddonTestUtils.promiseStartupManager(); + AddonTestUtils.usePrivilegedSignatures = false; +}); + +add_task(async function test_migrated_permission_to_optional() { + let id = "permission-upgrade@test"; + let extensionData = { + manifest: { + version: "1.0", + applications: { gecko: { id } }, + permissions: [ + "webRequest", + "tabs", + "http://example.net/*", + "http://example.com/*", + ], + }, + useAddonManager: "permanent", + }; + + function checkPermissions() { + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("webRequest"), "addon has webRequest permission"); + ok(policy.hasPermission("tabs"), "addon has tabs permission"); + ok( + policy.canAccessURI(Services.io.newURI("http://example.net/")), + "addon has example.net host permission" + ); + ok( + policy.canAccessURI(Services.io.newURI("http://example.com/")), + "addon has example.com host permission" + ); + ok( + !policy.canAccessURI(Services.io.newURI("http://other.com/")), + "addon does not have other.com host permission" + ); + } + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + checkPermissions(); + + // Move to using optional permission + extensionData.manifest.version = "2.0"; + extensionData.manifest.permissions = ["tabs", "http://example.net/*"]; + extensionData.manifest.optional_permissions = [ + "webRequest", + "http://example.com/*", + "http://other.com/*", + ]; + + // Restart the addon manager to flush the AddonInternal instance created + // when installing the addon above. See bug 1622117. + await AddonTestUtils.promiseRestartManager(); + await extension.upgrade(extensionData); + + equal(extension.version, "2.0", "Expected extension version"); + checkPermissions(); + + await extension.unload(); +}); + +// This tests that settings are removed if a required permission is removed. +// We use two settings APIs to make sure the one we keep permission to is not +// removed inadvertantly. +add_task(async function test_required_permissions_removed() { + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extData = { + background() { + if (browser.browserSettings) { + browser.browserSettings.cacheEnabled.set({ value: false }); + } + browser.privacy.services.passwordSavingEnabled.set({ value: false }); + }, + manifest: { + applications: { gecko: { id: "pref-test@test" } }, + permissions: ["tabs", "browserSettings", "privacy", "http://test.com/*"], + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + ok( + Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting intial value as expected" + ); + await extension.startup(); + ok(!cacheIsEnabled(), "setting is set after startup"); + + extData.manifest.permissions = ["tabs"]; + extData.manifest.optional_permissions = ["privacy"]; + await extension.upgrade(extData); + ok(cacheIsEnabled(), "setting is reset after upgrade"); + ok( + !Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting is still set after upgrade" + ); + + await extension.unload(); +}); + +// This tests that settings are removed if a granted permission is removed. +// We use two settings APIs to make sure the one we keep permission to is not +// removed inadvertantly. +add_task(async function test_granted_permissions_removed() { + function cacheIsEnabled() { + return ( + Services.prefs.getBoolPref("browser.cache.disk.enable") && + Services.prefs.getBoolPref("browser.cache.memory.enable") + ); + } + + let extData = { + async background() { + browser.test.onMessage.addListener(async msg => { + await browser.permissions.request({ permissions: msg.permissions }); + if (browser.browserSettings) { + browser.browserSettings.cacheEnabled.set({ value: false }); + } + browser.privacy.services.passwordSavingEnabled.set({ value: false }); + browser.test.sendMessage("done"); + }); + }, + // "tabs" is never granted, it is included to exercise the removal code + // that called during the upgrade. + manifest: { + applications: { gecko: { id: "pref-test@test" } }, + optional_permissions: [ + "tabs", + "browserSettings", + "privacy", + "http://test.com/*", + ], + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + ok( + Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting intial value as expected" + ); + await extension.startup(); + await withHandlingUserInput(extension, async () => { + extension.sendMessage({ permissions: ["browserSettings", "privacy"] }); + await extension.awaitMessage("done"); + }); + ok(!cacheIsEnabled(), "setting is set after startup"); + + extData.manifest.permissions = ["privacy"]; + delete extData.manifest.optional_permissions; + await extension.upgrade(extData); + ok(cacheIsEnabled(), "setting is reset after upgrade"); + ok( + !Services.prefs.getBoolPref("signon.rememberSignons"), + "privacy setting is still set after upgrade" + ); + + await extension.unload(); +}); + +// Test an update where an add-on becomes a theme. +add_task(async function test_addon_to_theme_update() { + let id = "theme-test@test"; + let extData = { + manifest: { + applications: { gecko: { id } }, + version: "1.0", + optional_permissions: ["tabs"], + }, + async background() { + browser.test.onMessage.addListener(async msg => { + await browser.permissions.request({ permissions: msg.permissions }); + browser.test.sendMessage("done"); + }); + }, + useAddonManager: "permanent", + }; + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage({ permissions: ["tabs"] }); + await extension.awaitMessage("done"); + }); + + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("tabs"), "addon has tabs permission"); + + await extension.upgrade({ + manifest: { + applications: { gecko: { id } }, + version: "2.0", + theme: { + images: { + theme_frame: "image1.png", + }, + }, + }, + useAddonManager: "permanent", + }); + // When a theme is installed, it starts off in disabled mode, as seen in + // toolkit/mozapps/extensions/test/xpcshell/test_update_theme.js . + // But if we upgrade from an enabled extension, the theme is enabled. + equal(extension.addon.userDisabled, false, "Theme is enabled"); + + policy = WebExtensionPolicy.getByID(id); + ok(!policy.hasPermission("tabs"), "addon tabs permission was removed"); + let perms = await ExtensionPermissions._get(id); + ok(!perms?.permissions?.length, "no retained permissions"); + + extData.manifest.version = "3.0"; + extData.manifest.permissions = ["privacy"]; + await extension.upgrade(extData); + + policy = WebExtensionPolicy.getByID(id); + ok(!policy.hasPermission("tabs"), "addon tabs permission not added"); + ok(policy.hasPermission("privacy"), "addon privacy permission added"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js new file mode 100644 index 0000000000..917a609e32 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_permissions_uninstall.js @@ -0,0 +1,160 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +const observer = { + observe(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + let { resolve } = subject.wrappedJSObject; + resolve(true); + } + }, +}; + +// Look up the cached permissions, if any. +async function getCachedPermissions(extensionId) { + const NotFound = Symbol("extension ID not found in permissions cache"); + try { + return await ExtensionParent.StartupCache.permissions.get( + extensionId, + () => { + // Throw error to prevent the key from being created. + throw NotFound; + } + ); + } catch (e) { + if (e === NotFound) { + return null; + } + throw e; + } +} + +// Look up the permissions from the file. Internal methods are used to avoid +// inadvertently changing the permissions in the cache or the database. +async function getStoredPermissions(extensionId) { + if (await ExtensionPermissions._has(extensionId)) { + return ExtensionPermissions._get(extensionId); + } + return null; +} + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + true + ); + Services.obs.addObserver(observer, "webextension-optional-permission-prompt"); + await AddonTestUtils.promiseStartupManager(); + registerCleanupFunction(async () => { + await AddonTestUtils.promiseShutdownManager(); + Services.obs.removeObserver( + observer, + "webextension-optional-permission-prompt" + ); + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); +}); + +// This test must run before any restart of the addonmanager so the +// ExtensionAddonObserver works. +add_task(async function test_permissions_removed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + optional_permissions: ["idle"], + }, + background() { + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg == "request") { + try { + let result = await browser.permissions.request(arg); + browser.test.sendMessage("request.result", result); + } catch (err) { + browser.test.sendMessage("request.result", err.message); + } + } + }); + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { permissions: ["idle"], origins: [] }); + let result = await extension.awaitMessage("request.result"); + equal(result, true, "request() for optional permissions succeeded"); + }); + + let id = extension.id; + let perms = await ExtensionPermissions.get(id); + equal(perms.permissions.length, 1, "optional permission added"); + + Assert.deepEqual( + await getCachedPermissions(id), + { + permissions: ["idle"], + origins: [], + }, + "Optional permission added to cache" + ); + Assert.deepEqual( + await getStoredPermissions(id), + { + permissions: ["idle"], + origins: [], + }, + "Optional permission added to persistent file" + ); + + await extension.unload(); + + // Directly read from the internals instead of using ExtensionPermissions.get, + // because the latter will lazily cache the extension ID. + Assert.deepEqual( + await getCachedPermissions(id), + null, + "Cached permissions removed" + ); + Assert.deepEqual( + await getStoredPermissions(id), + null, + "Stored permissions removed" + ); + + perms = await ExtensionPermissions.get(id); + equal(perms.permissions.length, 0, "no permissions after uninstall"); + equal(perms.origins.length, 0, "no origin permissions after uninstall"); + + // The public ExtensionPermissions.get method should not store (empty) + // permissions in the persistent database. Polluting the cache is not ideal, + // but acceptable since the cache will eventually be cleared, and non-test + // code is not likely to call ExtensionPermissions.get() for non-installed + // extensions anyway. + Assert.deepEqual(await getCachedPermissions(id), perms, "Permissions cached"); + Assert.deepEqual( + await getStoredPermissions(id), + null, + "Permissions not saved" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js new file mode 100644 index 0000000000..7acb383053 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_persistent_events.js @@ -0,0 +1,521 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { ExtensionAPI } = ExtensionCommon; + +const SCHEMA = [ + { + namespace: "eventtest", + events: [ + { + name: "onEvent1", + type: "function", + extraParameters: [{ type: "any" }], + }, + { + name: "onEvent2", + type: "function", + extraParameters: [{ type: "any" }], + }, + ], + }, +]; + +// The code in this class does not actually run in this test scope, it is +// serialized into a string which is later loaded by the WebExtensions +// framework in the same context as other extension APIs. By writing it +// this way rather than as a big string constant we get lint coverage. +// But eslint doesn't understand that this code runs in a different context +// where the EventManager class is available so just tell it here: +/* global EventManager */ +const API = class extends ExtensionAPI { + primeListener(extension, event, fire, params) { + Services.obs.notifyObservers( + { event, fire, params }, + "prime-event-listener" + ); + + const FIRE_TOPIC = `fire-${event}`; + + async function listener(subject, topic, data) { + try { + if (subject.wrappedJSObject.waitForBackground) { + await fire.wakeup(); + } + await fire.async(subject.wrappedJSObject.listenerArgs); + } catch (err) { + let errSubject = { event, errorMessage: err.toString() }; + Services.obs.notifyObservers(errSubject, "listener-callback-exception"); + } + } + Services.obs.addObserver(listener, FIRE_TOPIC); + + return { + unregister() { + Services.obs.notifyObservers( + { event, params }, + "unregister-primed-listener" + ); + Services.obs.removeObserver(listener, FIRE_TOPIC); + }, + convert(_fire) { + Services.obs.notifyObservers( + { event, params }, + "convert-event-listener" + ); + fire = _fire; + }, + }; + } + + getAPI(context) { + return { + eventtest: { + onEvent1: new EventManager({ + context, + name: "test.event1", + persistent: { + module: "eventtest", + event: "onEvent1", + }, + register: (fire, ...params) => { + let data = { event: "onEvent1", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + + onEvent2: new EventManager({ + context, + name: "test.event1", + persistent: { + module: "eventtest", + event: "onEvent2", + }, + register: (fire, ...params) => { + let data = { event: "onEvent2", params }; + Services.obs.notifyObservers(data, "register-event-listener"); + return () => { + Services.obs.notifyObservers(data, "unregister-event-listener"); + }; + }, + }).api(), + }, + }; + } +}; + +const API_SCRIPT = `this.eventtest = ${API.toString()}`; + +const MODULE_INFO = { + eventtest: { + schema: `data:,${JSON.stringify(SCHEMA)}`, + scopes: ["addon_parent"], + paths: [["eventtest"]], + url: URL.createObjectURL(new Blob([API_SCRIPT])), + }, +}; + +const global = this; + +// Wait for the given event (topic) to occur a specific number of times +// (count). If fn is not supplied, the Promise returned from this function +// resolves as soon as that many instances of the event have been observed. +// If fn is supplied, this function also waits for the Promise that fn() +// returns to complete and ensures that the given event does not occur more +// than `count` times before then. On success, resolves with an array +// of the subjects from each of the observed events. +async function promiseObservable(topic, count, fn = null) { + let _countResolve; + let results = []; + function listener(subject, _topic, data) { + results.push(subject.wrappedJSObject); + if (results.length > count) { + ok(false, `Got unexpected ${topic} event`); + } else if (results.length == count) { + _countResolve(); + } + } + Services.obs.addObserver(listener, topic); + + try { + await Promise.all([ + new Promise(resolve => { + _countResolve = resolve; + }), + fn && fn(), + ]); + } finally { + Services.obs.removeObserver(listener, topic); + } + + return results; +} + +add_task(async function setup() { + Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true + ); + + AddonTestUtils.init(global); + AddonTestUtils.overrideCertDB(); + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" + ); + + ExtensionParent.apiManager.registerModules(MODULE_INFO); +}); + +add_task(async function test_persistent_events() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let register1 = true, + register2 = true; + if (localStorage.getItem("skip1")) { + register1 = false; + } + if (localStorage.getItem("skip2")) { + register2 = false; + } + + let listener1 = arg => browser.test.sendMessage("listener1", arg); + let listener2 = arg => browser.test.sendMessage("listener2", arg); + let listener3 = arg => browser.test.sendMessage("listener3", arg); + + if (register1) { + browser.eventtest.onEvent1.addListener(listener1, "listener1"); + } + if (register2) { + browser.eventtest.onEvent1.addListener(listener2, "listener2"); + browser.eventtest.onEvent2.addListener(listener3, "listener3"); + } + + browser.test.onMessage.addListener(msg => { + if (msg == "unregister2") { + browser.eventtest.onEvent2.removeListener(listener3); + localStorage.setItem("skip2", true); + } else if (msg == "unregister1") { + localStorage.setItem("skip1", true); + browser.test.sendMessage("unregistered"); + } + }); + + browser.test.sendMessage("ready"); + }, + }); + + function check( + info, + what, + { listener1 = true, listener2 = true, listener3 = true } = {} + ) { + let count = (listener1 ? 1 : 0) + (listener2 ? 1 : 0) + (listener3 ? 1 : 0); + equal(info.length, count, `Got ${count} ${what} events`); + + let i = 0; + if (listener1) { + equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 1`); + deepEqual( + info[i].params, + ["listener1"], + `Got event1 ${what} args for listener 1` + ); + ++i; + } + + if (listener2) { + equal(info[i].event, "onEvent1", `Got ${what} on event1 for listener 2`); + deepEqual( + info[i].params, + ["listener2"], + `Got event1 ${what} args for listener 2` + ); + ++i; + } + + if (listener3) { + equal(info[i].event, "onEvent2", `Got ${what} on event2 for listener 3`); + deepEqual( + info[i].params, + ["listener3"], + `Got event2 ${what} args for listener 3` + ); + ++i; + } + } + + // Check that the regular event registration process occurs when + // the extension is installed. + let [info] = await Promise.all([ + promiseObservable("register-event-listener", 3), + extension.startup(), + ]); + check(info, "register"); + + await extension.awaitMessage("ready"); + + // Check that the regular unregister process occurs when + // the browser shuts down. + [info] = await Promise.all([ + promiseObservable("unregister-event-listener", 3), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + check(info, "unregister"); + + // Check that listeners are primed at the next browser startup. + [info] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(info, "prime"); + + // Check that primed listeners are converted to regular listeners + // when the background page is started after browser startup. + let p = promiseObservable("convert-event-listener", 3); + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + info = await p; + + check(info, "convert"); + + await extension.awaitMessage("ready"); + + // Check that when the event is triggered, all the plumbing worked + // correctly for the primed-then-converted listener. + let listenerArgs = { test: "kaboom" }; + Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent1"); + + let details = await extension.awaitMessage("listener1"); + deepEqual(details, listenerArgs, "Listener 1 fired"); + details = await extension.awaitMessage("listener2"); + deepEqual(details, listenerArgs, "Listener 2 fired"); + + // Check that the converted listener is properly unregistered at + // browser shutdown. + [info] = await Promise.all([ + promiseObservable("unregister-primed-listener", 3), + AddonTestUtils.promiseShutdownManager(), + ]); + check(info, "unregister"); + + // Start up again, listener should be primed + [info] = await Promise.all([ + promiseObservable("prime-event-listener", 3), + AddonTestUtils.promiseStartupManager(), + ]); + check(info, "prime"); + + // Check that triggering the event before the listener has been converted + // causes the background page to be loaded and the listener to be converted, + // and the listener is invoked. + p = promiseObservable("convert-event-listener", 3); + listenerArgs.test = "startup event"; + Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent2"); + info = await p; + + check(info, "convert"); + + details = await extension.awaitMessage("listener3"); + deepEqual(details, listenerArgs, "Listener 3 fired for event during startup"); + + await extension.awaitMessage("ready"); + + // Check that the unregister process works when we manually remove + // a listener. + p = promiseObservable("unregister-primed-listener", 1); + extension.sendMessage("unregister2"); + info = await p; + check(info, "unregister", { listener1: false, listener2: false }); + + // Check that we only get unregisters for the remaining events after + // one listener has been removed. + info = await promiseObservable("unregister-primed-listener", 2, () => + AddonTestUtils.promiseShutdownManager() + ); + check(info, "unregister", { listener3: false }); + + // Check that after restart, only listeners that were present at + // the end of the last session are primed. + info = await promiseObservable("prime-event-listener", 2, () => + AddonTestUtils.promiseStartupManager() + ); + check(info, "prime", { listener3: false }); + + // Check that if the background script does not re-register listeners, + // the primed listeners are unregistered after the background page + // starts up. + p = promiseObservable("unregister-primed-listener", 1, () => + extension.awaitMessage("ready") + ); + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + info = await p; + check(info, "unregister", { listener1: false, listener3: false }); + + // Just listener1 should be registered now, fire event1 to confirm. + listenerArgs.test = "third time"; + Services.obs.notifyObservers({ listenerArgs }, "fire-onEvent1"); + details = await extension.awaitMessage("listener1"); + deepEqual(details, listenerArgs, "Listener 1 fired"); + + // Tell the extension not to re-register listener1 on the next startup + extension.sendMessage("unregister1"); + await extension.awaitMessage("unregistered"); + + // Shut down, start up + info = await promiseObservable("unregister-primed-listener", 1, () => + AddonTestUtils.promiseShutdownManager() + ); + check(info, "unregister", { listener2: false, listener3: false }); + + info = await promiseObservable("prime-event-listener", 1, () => + AddonTestUtils.promiseStartupManager() + ); + check(info, "register", { listener2: false, listener3: false }); + + // Check that firing event1 causes the listener fire callback to + // reject. + p = promiseObservable("listener-callback-exception", 1); + Services.obs.notifyObservers( + { listenerArgs, waitForBackground: true }, + "fire-onEvent1" + ); + equal( + (await p)[0].errorMessage, + "Error: primed listener not re-registered", + "Primed listener that was not re-registered received an error when event was triggered during startup" + ); + + await extension.awaitMessage("ready"); + + await extension.unload(); + + await AddonTestUtils.promiseShutdownManager(); +}); + +// This test checks whether primed listeners are correctly unregistered when +// a background page load is interrupted. In particular, it verifies that the +// fire.wakeup() and fire.async() promises settle eventually. +add_task(async function test_shutdown_before_background_loaded() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + background() { + let listener = arg => browser.test.sendMessage("triggered", arg); + browser.eventtest.onEvent1.addListener(listener, "triggered"); + browser.test.sendMessage("bg_started"); + }, + }); + await Promise.all([ + promiseObservable("register-event-listener", 1), + extension.startup(), + ]); + await extension.awaitMessage("bg_started"); + + await Promise.all([ + promiseObservable("unregister-event-listener", 1), + new Promise(resolve => extension.extension.once("shutdown", resolve)), + AddonTestUtils.promiseShutdownManager(), + ]); + + let primeListenerPromise = promiseObservable("prime-event-listener", 1); + let fire; + let fireWakeupBeforeBgFail; + let fireAsyncBeforeBgFail; + + let bgAbortedPromise = new Promise(resolve => { + let Management = ExtensionParent.apiManager; + Management.once("extension-browser-inserted", (eventName, browser) => { + browser.loadURI = async () => { + // The fire.wakeup/fire.async promises created while loading the + // background page should settle when the page fails to load. + fire = (await primeListenerPromise)[0].fire; + fireWakeupBeforeBgFail = fire.wakeup(); + fireAsyncBeforeBgFail = fire.async(); + + extension.extension.once("background-page-aborted", resolve); + info("Forcing the background load to fail"); + browser.remove(); + }; + }); + }); + + let unregisterPromise = promiseObservable("unregister-primed-listener", 1); + + await Promise.all([ + primeListenerPromise, + AddonTestUtils.promiseStartupManager(), + ]); + await bgAbortedPromise; + info("Loaded extension and aborted load of background page"); + + await unregisterPromise; + info("Primed listener has been unregistered"); + + await fireWakeupBeforeBgFail; + info("fire.wakeup() before background load failure should settle"); + + await Assert.rejects( + fireAsyncBeforeBgFail, + /Error: listener not re-registered/, + "fire.async before background load failure should be rejected" + ); + + await fire.wakeup(); + info("fire.wakeup() after background load failure should settle"); + + await Assert.rejects( + fire.async(), + /Error: primed listener not re-registered/, + "fire.async after background load failure should be rejected" + ); + + await AddonTestUtils.promiseShutdownManager(); + + // End of the abnormal shutdown test. Now restart the extension to verify + // that the persistent listeners have not been unregistered. + + // Suppress background page start until an explicit notification. + ExtensionParent._resetStartupPromises(); + await Promise.all([ + promiseObservable("prime-event-listener", 1), + AddonTestUtils.promiseStartupManager(), + ]); + info("Triggering persistent event to force the background page to start"); + Services.obs.notifyObservers({ listenerArgs: 123 }, "fire-onEvent1"); + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await extension.awaitMessage("bg_started"); + equal(await extension.awaitMessage("triggered"), 123, "triggered event"); + + await Promise.all([ + promiseObservable("unregister-primed-listener", 1), + AddonTestUtils.promiseShutdownManager(), + ]); + + // And lastly, verify that a primed listener is correctly removed when the + // extension unloads normally before the delayed background page can load. + ExtensionParent._resetStartupPromises(); + await Promise.all([ + promiseObservable("prime-event-listener", 1), + AddonTestUtils.promiseStartupManager(), + ]); + + info("Unloading extension before background page has loaded"); + await Promise.all([ + promiseObservable("unregister-primed-listener", 1), + extension.unload(), + ]); + + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js new file mode 100644 index 0000000000..14a18b8fac --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy.js @@ -0,0 +1,964 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// Currently security.tls.version.min has a different default +// value in Nightly and Beta as opposed to Release builds. +const tlsMinPref = Services.prefs.getIntPref("security.tls.version.min"); +if (tlsMinPref != 1 && tlsMinPref != 3) { + ok(false, "This test expects security.tls.version.min set to 1 or 3."); +} +const tlsMinVer = tlsMinPref === 3 ? "TLSv1.2" : "TLSv1"; + +add_task(async function test_privacy() { + // Create an object to hold the values to which we will initialize the prefs. + const SETTINGS = { + "network.networkPredictionEnabled": { + "network.predictor.enabled": true, + "network.prefetch-next": true, + // This pref starts with a numerical value and we need to use whatever the + // default is or we encounter issues when the pref is reset during the test. + "network.http.speculative-parallel-limit": ExtensionPreferencesManager.getDefaultValue( + "network.http.speculative-parallel-limit" + ), + "network.dns.disablePrefetch": false, + }, + "websites.hyperlinkAuditingEnabled": { + "browser.send_pings": true, + }, + }; + + async function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let data = args[0]; + // The second argument is the end of the api name, + // e.g., "network.networkPredictionEnabled". + let apiObj = args[1].split(".").reduce((o, i) => o[i], browser.privacy); + let settingData; + switch (msg) { + case "get": + settingData = await apiObj.get(data); + browser.test.sendMessage("gotData", settingData); + break; + + case "set": + await apiObj.set(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("afterSet", settingData); + break; + + case "clear": + await apiObj.clear(data); + settingData = await apiObj.get({}); + browser.test.sendMessage("afterClear", settingData); + break; + } + }); + } + + // Set prefs to our initial values. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.set(pref, SETTINGS[setting][pref]); + } + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.reset(pref); + } + } + }); + + await promiseStartupManager(); + + // Create an array of extensions to install. + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + for (let setting in SETTINGS) { + testExtensions[0].sendMessage("get", {}, setting); + let data = await testExtensions[0].awaitMessage("gotData"); + ok(data.value, "get returns expected value."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "get returns expected levelOfControl." + ); + + testExtensions[0].sendMessage("get", { incognito: true }, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(data.value, "get returns expected value with incognito."); + equal( + data.levelOfControl, + "not_controllable", + "get returns expected levelOfControl with incognito." + ); + + // Change the value to false. + testExtensions[0].sendMessage("set", { value: false }, setting); + data = await testExtensions[0].awaitMessage("afterSet"); + ok(!data.value, "get returns expected value after setting."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Verify the prefs have been set to match the "false" setting. + for (let pref in SETTINGS[setting]) { + let msg = `${pref} set correctly for ${setting}`; + if (pref === "network.http.speculative-parallel-limit") { + equal(Preferences.get(pref), 0, msg); + } else { + equal(Preferences.get(pref), !SETTINGS[setting][pref], msg); + } + } + + // Change the value with a newer extension. + testExtensions[1].sendMessage("set", { value: true }, setting); + data = await testExtensions[1].awaitMessage("afterSet"); + ok( + data.value, + "get returns expected value after setting via newer extension." + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Verify the prefs have been set to match the "true" setting. + for (let pref in SETTINGS[setting]) { + let msg = `${pref} set correctly for ${setting}`; + if (pref === "network.http.speculative-parallel-limit") { + equal( + Preferences.get(pref), + ExtensionPreferencesManager.getDefaultValue(pref), + msg + ); + } else { + equal(Preferences.get(pref), SETTINGS[setting][pref], msg); + } + } + + // Change the value with an older extension. + testExtensions[0].sendMessage("set", { value: false }, setting); + data = await testExtensions[0].awaitMessage("afterSet"); + ok(data.value, "Newer extension remains in control."); + equal( + data.levelOfControl, + "controlled_by_other_extensions", + "get returns expected levelOfControl when controlled by other." + ); + + // Clear the value of the newer extension. + testExtensions[1].sendMessage("clear", {}, setting); + data = await testExtensions[1].awaitMessage("afterClear"); + ok(!data.value, "Older extension gains control."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + testExtensions[0].sendMessage("get", {}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(!data.value, "Current, older extension has control."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + // Set the value again with the newer extension. + testExtensions[1].sendMessage("set", { value: true }, setting); + data = await testExtensions[1].awaitMessage("afterSet"); + ok( + data.value, + "get returns expected value after setting via newer extension." + ); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "get returns expected levelOfControl after setting." + ); + + // Unload the newer extension. Expect the older extension to regain control. + await testExtensions[1].unload(); + testExtensions[0].sendMessage("get", {}, setting); + data = await testExtensions[0].awaitMessage("gotData"); + ok(!data.value, "Older extension regained control."); + equal( + data.levelOfControl, + "controlled_by_this_extension", + "Expected levelOfControl returned after unloading." + ); + + // Reload the extension for the next iteration of the loop. + testExtensions[1] = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }); + await testExtensions[1].startup(); + + // Clear the value of the older extension. + testExtensions[0].sendMessage("clear", {}, setting); + data = await testExtensions[0].awaitMessage("afterClear"); + ok(data.value, "Setting returns to original value when all are cleared."); + equal( + data.levelOfControl, + "controllable_by_this_extension", + "Expected levelOfControl returned after clearing." + ); + + // Verify that our initial values were restored. + for (let pref in SETTINGS[setting]) { + equal( + Preferences.get(pref), + SETTINGS[setting][pref], + `${pref} was reset to its initial value.` + ); + } + } + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); + +add_task(async function test_privacy_other_prefs() { + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.tls.version.min"); + Services.prefs.clearUserPref("security.tls.version.max"); + }); + + const cookieSvc = Ci.nsICookieService; + + // Create an object to hold the values to which we will initialize the prefs. + const SETTINGS = { + "network.webRTCIPHandlingPolicy": { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + }, + "network.tlsVersionRestriction": { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 4, + }, + "network.peerConnectionEnabled": { + "media.peerconnection.enabled": true, + }, + "services.passwordSavingEnabled": { + "signon.rememberSignons": true, + }, + "websites.referrersEnabled": { + "network.http.sendRefererHeader": 2, + }, + "websites.resistFingerprinting": { + "privacy.resistFingerprinting": true, + }, + "websites.firstPartyIsolate": { + "privacy.firstparty.isolate": false, + }, + "websites.cookieConfig": { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + }; + + let defaultPrefs = new Preferences({ defaultBranch: true }); + let defaultCookieBehavior = defaultPrefs.get("network.cookie.cookieBehavior"); + let defaultBehavior; + switch (defaultCookieBehavior) { + case cookieSvc.BEHAVIOR_ACCEPT: + defaultBehavior = "allow_all"; + break; + case cookieSvc.BEHAVIOR_REJECT_FOREIGN: + defaultBehavior = "reject_third_party"; + break; + case cookieSvc.BEHAVIOR_REJECT: + defaultBehavior = "reject_all"; + break; + case cookieSvc.BEHAVIOR_LIMIT_FOREIGN: + defaultBehavior = "allow_visited"; + break; + case cookieSvc.BEHAVIOR_REJECT_TRACKER: + defaultBehavior = "reject_trackers"; + break; + case cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN: + defaultBehavior = "reject_trackers_and_partition_foreign"; + break; + default: + ok( + false, + `Unexpected cookie behavior encountered: ${defaultCookieBehavior}` + ); + break; + } + + async function background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + let data = args[0]; + // The second argument is the end of the api name, + // e.g., "network.webRTCIPHandlingPolicy". + let apiObj = args[1].split(".").reduce((o, i) => o[i], browser.privacy); + let settingData; + switch (msg) { + case "set": + try { + await apiObj.set(data); + } catch (e) { + browser.test.sendMessage("settingThrowsException", { + message: e.message, + }); + break; + } + settingData = await apiObj.get({}); + browser.test.sendMessage("settingData", settingData); + break; + case "get": + settingData = await apiObj.get({}); + browser.test.sendMessage("gettingData", settingData); + break; + } + }); + } + + // Set prefs to our initial values. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.set(pref, SETTINGS[setting][pref]); + } + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let setting in SETTINGS) { + for (let pref in SETTINGS[setting]) { + Preferences.reset(pref); + } + } + }); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + async function testSetting(setting, value, expected, expectedValue = value) { + extension.sendMessage("set", { value: value }, setting); + let data = await extension.awaitMessage("settingData"); + deepEqual( + data.value, + expectedValue, + `Got expected result on setting ${setting} to ${uneval(value)}` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${expected[pref]}` + ); + } + } + + async function testSettingException(setting, value, expected) { + extension.sendMessage("set", { value: value }, setting); + let data = await extension.awaitMessage("settingThrowsException"); + equal(data.message, expected); + } + + async function testGetting(getting, expected, expectedValue) { + extension.sendMessage("get", null, getting); + let data = await extension.awaitMessage("gettingData"); + deepEqual( + data.value, + expectedValue, + `Got expected result on getting ${getting}` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} get correctly for ${expected[pref]}` + ); + } + } + + await testSetting( + "network.webRTCIPHandlingPolicy", + "default_public_and_private_interfaces", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting( + "network.webRTCIPHandlingPolicy", + "default_public_interface_only", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": true, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting( + "network.webRTCIPHandlingPolicy", + "disable_non_proxied_udp", + { + "media.peerconnection.ice.default_address_only": true, + "media.peerconnection.ice.no_host": true, + "media.peerconnection.ice.proxy_only_if_behind_proxy": true, + "media.peerconnection.ice.proxy_only": false, + } + ); + await testSetting("network.webRTCIPHandlingPolicy", "proxy_only", { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": true, + }); + await testSetting("network.webRTCIPHandlingPolicy", "default", { + "media.peerconnection.ice.default_address_only": false, + "media.peerconnection.ice.no_host": false, + "media.peerconnection.ice.proxy_only_if_behind_proxy": false, + "media.peerconnection.ice.proxy_only": false, + }); + + await testSetting("network.peerConnectionEnabled", false, { + "media.peerconnection.enabled": false, + }); + await testSetting("network.peerConnectionEnabled", true, { + "media.peerconnection.enabled": true, + }); + + await testSetting("websites.referrersEnabled", false, { + "network.http.sendRefererHeader": 0, + }); + await testSetting("websites.referrersEnabled", true, { + "network.http.sendRefererHeader": 2, + }); + + await testSetting("websites.resistFingerprinting", false, { + "privacy.resistFingerprinting": false, + }); + await testSetting("websites.resistFingerprinting", true, { + "privacy.resistFingerprinting": true, + }); + + await testSetting("websites.trackingProtectionMode", "always", { + "privacy.trackingprotection.enabled": true, + "privacy.trackingprotection.pbmode.enabled": true, + }); + await testSetting("websites.trackingProtectionMode", "never", { + "privacy.trackingprotection.enabled": false, + "privacy.trackingprotection.pbmode.enabled": false, + }); + await testSetting("websites.trackingProtectionMode", "private_browsing", { + "privacy.trackingprotection.enabled": false, + "privacy.trackingprotection.pbmode.enabled": true, + }); + + await testSetting("services.passwordSavingEnabled", false, { + "signon.rememberSignons": false, + }); + await testSetting("services.passwordSavingEnabled", true, { + "signon.rememberSignons": true, + }); + + await testSetting( + "websites.cookieConfig", + { behavior: "reject_third_party", nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION, + } + ); + // A missing nonPersistentCookies property should default to false. + await testSetting( + "websites.cookieConfig", + { behavior: "reject_third_party" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "reject_third_party", nonPersistentCookies: false } + ); + // A missing behavior property should reset the pref. + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION, + }, + { behavior: defaultBehavior, nonPersistentCookies: true } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_all" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "reject_all", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "allow_visited" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_LIMIT_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "allow_visited", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "allow_all" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_ACCEPT, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "allow_all", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: true }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_SESSION, + }, + { behavior: defaultBehavior, nonPersistentCookies: true } + ); + await testSetting( + "websites.cookieConfig", + { nonPersistentCookies: false }, + { + "network.cookie.cookieBehavior": defaultCookieBehavior, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: defaultBehavior, nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "reject_trackers", nonPersistentCookies: false } + ); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + // 1. Can't enable FPI when cookie behavior is "reject_trackers_and_partition_foreign" + await testSettingException( + "websites.firstPartyIsolate", + true, + "Can't enable firstPartyIsolate when cookieBehavior is 'reject_trackers_and_partition_foreign'" + ); + + // 2. Change cookieConfig to reject_trackers should work normally. + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers" }, + { + "network.cookie.cookieBehavior": cookieSvc.BEHAVIOR_REJECT_TRACKER, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { behavior: "reject_trackers", nonPersistentCookies: false } + ); + + // 3. Enable FPI + await testSetting("websites.firstPartyIsolate", true, { + "privacy.firstparty.isolate": true, + }); + + // 4. When FPI is enabled, change setting to "reject_trackers_and_partition_foreign" is invalid + await testSettingException( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + "Invalid cookieConfig 'reject_trackers_and_partition_foreign' when firstPartyIsolate is enabled" + ); + + // 5. Set conflict settings manually and check prefs. + Preferences.set("network.cookie.cookieBehavior", 5); + await testGetting( + "websites.firstPartyIsolate", + { "privacy.firstparty.isolate": true }, + true + ); + await testGetting( + "websites.cookieConfig", + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + // 6. It is okay to set current saved value. + await testSetting("websites.firstPartyIsolate", true, { + "privacy.firstparty.isolate": true, + }); + await testSetting( + "websites.cookieConfig", + { behavior: "reject_trackers_and_partition_foreign" }, + { + "network.cookie.cookieBehavior": + cookieSvc.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, + "network.cookie.lifetimePolicy": cookieSvc.ACCEPT_NORMALLY, + }, + { + behavior: "reject_trackers_and_partition_foreign", + nonPersistentCookies: false, + } + ); + + await testSetting("websites.firstPartyIsolate", false, { + "privacy.firstparty.isolate": false, + }); + + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + maximum: "TLSv1.3", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 4, + } + ); + + // Single values + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + } + ); + + // Single values + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + } + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "invalid", + maximum: "invalid", + }, + "Setting TLS version invalid is not allowed for security reasons." + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "invalid2", + }, + "Setting TLS version invalid2 is not allowed for security reasons." + ); + + // Invalid values. + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "invalid3", + }, + "Setting TLS version invalid3 is not allowed for security reasons." + ); + + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 4, + }, + { + minimum: "TLSv1.2", + maximum: "TLSv1.3", + } + ); + + await testSetting( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.2", + }, + { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 3, + }, + { + minimum: tlsMinVer, + maximum: "TLSv1.2", + } + ); + + // Not supported version. + if (tlsMinPref === 3) { + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1", + }, + "Setting TLS version TLSv1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.1", + }, + "Setting TLS version TLSv1.1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1", + }, + "Setting TLS version TLSv1 is not allowed for security reasons." + ); + + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.1", + }, + "Setting TLS version TLSv1.1 is not allowed for security reasons." + ); + } + + // Min vs Max + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + maximum: "TLSv1.2", + }, + "Setting TLS min version grater than the max version is not allowed." + ); + + // Min vs Max (with default max) + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.2", + maximum: "TLSv1.2", + }, + { + "security.tls.version.min": 3, + "security.tls.version.max": 3, + } + ); + await testSettingException( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + }, + "Setting TLS min version grater than the max version is not allowed." + ); + + // Max vs Min + await testSetting( + "network.tlsVersionRestriction", + { + minimum: "TLSv1.3", + maximum: "TLSv1.3", + }, + { + "security.tls.version.min": 4, + "security.tls.version.max": 4, + } + ); + await testSettingException( + "network.tlsVersionRestriction", + { + maximum: "TLSv1.2", + }, + "Setting TLS max version lower than the min version is not allowed." + ); + + // Empty value. + await testSetting( + "network.tlsVersionRestriction", + {}, + { + "security.tls.version.min": tlsMinPref, + "security.tls.version.max": 4, + }, + { + minimum: tlsMinVer, + maximum: "TLSv1.3", + } + ); + + const HTTPS_ONLY_PREF_NAME = "dom.security.https_only_mode"; + const HTTPS_ONLY_PBM_PREF_NAME = "dom.security.https_only_mode_pbm"; + + Preferences.set(HTTPS_ONLY_PREF_NAME, false); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false); + await testGetting("network.httpsOnlyMode", {}, "never"); + + Preferences.set(HTTPS_ONLY_PREF_NAME, true); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, false); + await testGetting("network.httpsOnlyMode", {}, "always"); + + Preferences.set(HTTPS_ONLY_PREF_NAME, false); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true); + await testGetting("network.httpsOnlyMode", {}, "private_browsing"); + + // Please note that if https_only_mode = true, then + // https_only_mode_pbm has no effect. + Preferences.set(HTTPS_ONLY_PREF_NAME, true); + Preferences.set(HTTPS_ONLY_PBM_PREF_NAME, true); + await testGetting("network.httpsOnlyMode", {}, "always"); + + // trying to "set" should have no effect when readonly! + extension.sendMessage("set", { value: "never" }, "network.httpsOnlyMode"); + let readOnlyData = await extension.awaitMessage("settingData"); + equal(readOnlyData.value, "always"); + + equal(Preferences.get(HTTPS_ONLY_PREF_NAME), true); + equal(Preferences.get(HTTPS_ONLY_PBM_PREF_NAME), true); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_exceptions() { + async function background() { + await browser.test.assertRejects( + browser.privacy.network.networkPredictionEnabled.set({ + value: true, + scope: "regular_only", + }), + "Firefox does not support the regular_only settings scope.", + "Expected rejection calling set with invalid scope." + ); + + await browser.test.assertRejects( + browser.privacy.network.networkPredictionEnabled.clear({ + scope: "incognito_persistent", + }), + "Firefox does not support the incognito_persistent settings scope.", + "Expected rejection calling clear with invalid scope." + ); + + browser.test.notifyPass("exceptionTests"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["privacy"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("exceptionTests"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js new file mode 100644 index 0000000000..ff0d4d9d48 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_disable.js @@ -0,0 +1,201 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +XPCOMUtils.defineLazyGetter(this, "Management", () => { + // eslint-disable-next-line no-shadow + const { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null + ); + return Management; +}); + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ExtensionPreferencesManager", + "resource://gre/modules/ExtensionPreferencesManager.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +function awaitEvent(eventName) { + return new Promise(resolve => { + let listener = (_eventName, ...args) => { + if (_eventName === eventName) { + Management.off(eventName, listener); + resolve(...args); + } + }; + + Management.on(eventName, listener); + }); +} + +function awaitPrefChange(prefName) { + return new Promise(resolve => { + let listener = args => { + Preferences.ignore(prefName, listener); + resolve(); + }; + + Preferences.observe(prefName, listener); + }); +} + +add_task(async function test_disable() { + const OLD_ID = "old_id@tests.mozilla.org"; + const NEW_ID = "new_id@tests.mozilla.org"; + + const PREF_TO_WATCH = "network.http.speculative-parallel-limit"; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + function checkPrefs(expected) { + for (let pref in PREFS) { + let msg = `${pref} set correctly.`; + let expectedValue = expected ? PREFS[pref] : !PREFS[pref]; + if (pref === "network.http.speculative-parallel-limit") { + expectedValue = expected + ? ExtensionPreferencesManager.getDefaultValue(pref) + : 0; + } + equal(Preferences.get(pref), expectedValue, msg); + } + } + + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + await browser.privacy.network.networkPredictionEnabled.set(data); + let settingData = await browser.privacy.network.networkPredictionEnabled.get( + {} + ); + browser.test.sendMessage("privacyData", settingData); + }); + } + + await promiseStartupManager(); + + let testExtensions = [ + ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { + gecko: { + id: OLD_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + + ExtensionTestUtils.loadExtension({ + background, + manifest: { + applications: { + gecko: { + id: NEW_ID, + }, + }, + permissions: ["privacy"], + }, + useAddonManager: "temporary", + }), + ]; + + for (let extension of testExtensions) { + await extension.startup(); + } + + // Set the value to true for the older extension. + testExtensions[0].sendMessage("set", { value: true }); + let data = await testExtensions[0].awaitMessage("privacyData"); + ok(data.value, "Value set to true for the older extension."); + + // Set the value to false for the newest extension. + testExtensions[1].sendMessage("set", { value: false }); + data = await testExtensions[1].awaitMessage("privacyData"); + ok(!data.value, "Value set to false for the newest extension."); + + // Verify the prefs have been set to match the "false" setting. + checkPrefs(false); + + // Disable the newest extension. + let disabledPromise = awaitPrefChange(PREF_TO_WATCH); + let newAddon = await AddonManager.getAddonByID(NEW_ID); + await newAddon.disable(); + await disabledPromise; + + // Verify the prefs have been set to match the "true" setting. + checkPrefs(true); + + // Disable the older extension. + disabledPromise = awaitPrefChange(PREF_TO_WATCH); + let oldAddon = await AddonManager.getAddonByID(OLD_ID); + await oldAddon.disable(); + await disabledPromise; + + // Verify the prefs have reverted back to their initial values. + for (let pref in PREFS) { + equal(Preferences.get(pref), PREFS[pref], `${pref} reset correctly.`); + } + + // Re-enable the newest extension. + let enabledPromise = awaitEvent("ready"); + await newAddon.enable(); + await enabledPromise; + + // Verify the prefs have been set to match the "false" setting. + checkPrefs(false); + + // Re-enable the older extension. + enabledPromise = awaitEvent("ready"); + await oldAddon.enable(); + await enabledPromise; + + // Verify the prefs have remained set to match the "false" setting. + checkPrefs(false); + + for (let extension of testExtensions) { + await extension.unload(); + } + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js new file mode 100644 index 0000000000..8b9ae6be9c --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_privacy_update.js @@ -0,0 +1,167 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_privacy_update() { + // Create a object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.predictor.enabled": true, + "network.prefetch-next": true, + "network.http.speculative-parallel-limit": 10, + "network.dns.disablePrefetch": false, + }; + + const EXTENSION_ID = "test_privacy_addon_update@tests.mozilla.org"; + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + let settingData; + switch (msg) { + case "get": + settingData = await browser.privacy.network.networkPredictionEnabled.get( + {} + ); + browser.test.sendMessage("privacyData", settingData); + break; + + case "set": + await browser.privacy.network.networkPredictionEnabled.set(data); + settingData = await browser.privacy.network.networkPredictionEnabled.get( + {} + ); + browser.test.sendMessage("privacyData", settingData); + break; + } + }); + } + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_privacy-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + permissions: ["privacy"], + }, + background, + }); + + testServer.registerFile("/addons/test_privacy-2.0.xpi", webExtensionFile); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + update_url: `http://localhost:${port}/test_update.json`, + }, + }, + permissions: ["privacy"], + }, + background, + }); + + await extension.startup(); + + // Change the value to false. + extension.sendMessage("set", { value: false }); + let data = await extension.awaitMessage("privacyData"); + ok(!data.value, "get returns expected value after setting."); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + + let update = await promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + + await promiseCompleteAllInstalls([install]); + + await extension.awaitStartup(); + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + + extension.sendMessage("get"); + data = await extension.awaitMessage("privacyData"); + ok(!data.value, "get returns expected value after updating."); + + // Verify the prefs are still set to match the "false" setting. + for (let pref in PREFS) { + let msg = `${pref} set correctly.`; + let expectedValue = + pref === "network.http.speculative-parallel-limit" ? 0 : !PREFS[pref]; + equal(Preferences.get(pref), expectedValue, msg); + } + + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js new file mode 100644 index 0000000000..27f537b73b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_authorization_via_proxyinfo.js @@ -0,0 +1,116 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "authManager", + "@mozilla.org/network/http-auth-manager;1", + "nsIHttpAuthManager" +); + +const proxy = createHttpServer(); +const proxyToken = "this_is_my_pass"; + +// accept proxy connections for mozilla.org +proxy.identity.add("http", "mozilla.org", 80); + +proxy.registerPathHandler("/", (request, response) => { + if (request.hasHeader("Proxy-Authorization")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + response.write(request.getHeader("Proxy-Authorization")); + } else { + response.setStatusLine( + request.httpVersion, + 407, + "Proxy authentication required" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", "UnknownMeantToFail", false); + response.write("auth required"); + } +}); + +function getExtension(background) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${proxy.identity.primaryPort}, "${proxyToken}")`, + }); +} + +add_task(async function test_webRequest_auth_proxy() { + function background(port, proxyToken) { + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set" + ); + browser.test.assertEq( + proxyToken, + details.proxyInfo.proxyAuthorizationHeader, + "proxy authorization header" + ); + browser.test.assertEq( + proxyToken, + details.proxyInfo.connectionIsolationKey, + "proxy connection isolation" + ); + + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + // Using proxyAuthorizationHeader should prevent an auth request coming to us in the extension. + browser.test.fail("onAuthRequired"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [ + { + host: "localhost", + port, + type: "http", + proxyAuthorizationHeader: proxyToken, + connectionIsolationKey: proxyToken, + }, + ]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + } + + let extension = getExtension(background); + + await extension.startup(); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://mozilla.org/` + ); + + await extension.awaitFinish("requestCompleted"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js new file mode 100644 index 0000000000..953bf4bea5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_config.js @@ -0,0 +1,633 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Preferences", + "resource://gre/modules/Preferences.jsm" +); + +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, the legacy + // storage mode will run in xpcshell-legacy-ep.ini + await ExtensionPermissions._uninit(); + + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_browser_settings() { + const proxySvc = Ci.nsIProtocolProxyService; + + // Create an object to hold the values to which we will initialize the prefs. + const PREFS = { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "network.proxy.http": "", + "network.proxy.http_port": 0, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "", + "network.proxy.ftp_port": 0, + "network.proxy.ssl": "", + "network.proxy.ssl_port": 0, + "network.proxy.socks": "", + "network.proxy.socks_port": 0, + "network.proxy.socks_version": 5, + "network.proxy.socks_remote_dns": false, + "network.proxy.no_proxies_on": "", + "network.proxy.autoconfig_url": "", + "signon.autologin.proxy": false, + }; + + async function background() { + browser.test.onMessage.addListener(async (msg, value) => { + let apiObj = browser.proxy.settings; + let result = await apiObj.set({ value }); + if (msg === "set") { + browser.test.assertTrue(result, "set returns true."); + browser.test.sendMessage("settingData", await apiObj.get({})); + } else { + browser.test.assertFalse(result, "set returns false for a no-op."); + browser.test.sendMessage("no-op set"); + } + }); + } + + // Set prefs to our initial values. + for (let pref in PREFS) { + Preferences.set(pref, PREFS[pref]); + } + + registerCleanupFunction(() => { + // Reset the prefs. + for (let pref in PREFS) { + Preferences.reset(pref); + } + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["proxy"], + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + }); + + await extension.startup(); + + async function testSetting(value, expected, expectedValue = value) { + extension.sendMessage("set", value); + let data = await extension.awaitMessage("settingData"); + deepEqual(data.value, expectedValue, `The setting has the expected value.`); + equal( + data.levelOfControl, + "controlled_by_this_extension", + `The setting has the expected levelOfControl.` + ); + for (let pref in expected) { + equal( + Preferences.get(pref), + expected[pref], + `${pref} set correctly for ${value}` + ); + } + } + + async function testProxy(config, expectedPrefs, expectedConfig = config) { + // proxy.settings is not supported on Android. + if (AppConstants.platform === "android") { + return Promise.resolve(); + } + + let proxyConfig = { + proxyType: "none", + autoConfigUrl: "", + autoLogin: false, + proxyDNS: false, + httpProxyAll: false, + socksVersion: 5, + passthrough: "", + http: "", + ftp: "", + ssl: "", + socks: "", + respectBeConservative: true, + }; + + expectedConfig.proxyType = expectedConfig.proxyType || "system"; + + return testSetting( + config, + expectedPrefs, + Object.assign(proxyConfig, expectedConfig) + ); + } + + await testProxy( + { proxyType: "none" }, + { "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT } + ); + + await testProxy( + { + proxyType: "autoDetect", + autoLogin: true, + proxyDNS: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_WPAD, + "signon.autologin.proxy": true, + "network.proxy.socks_remote_dns": true, + } + ); + + await testProxy( + { + proxyType: "system", + autoLogin: false, + proxyDNS: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "signon.autologin.proxy": false, + "network.proxy.socks_remote_dns": false, + } + ); + + // Verify that proxyType is optional and it defaults to "system". + await testProxy( + { + autoLogin: false, + proxyDNS: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_SYSTEM, + "signon.autologin.proxy": false, + "network.proxy.socks_remote_dns": false, + "network.http.proxy.respect-be-conservative": true, + } + ); + + await testProxy( + { + proxyType: "autoConfig", + autoConfigUrl: "http://mozilla.org", + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_PAC, + "network.proxy.autoconfig_url": "http://mozilla.org", + "network.http.proxy.respect-be-conservative": true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org", + autoConfigUrl: "", + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.autoconfig_url": "", + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + autoConfigUrl: "", + } + ); + + // When using proxyAll, we expect all proxies to be set to + // be the same as http. + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:8080", + ftp: "http://www.mozilla.org:1234", + httpProxyAll: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 8080, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 8080, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 8080, + "network.proxy.share_proxy_settings": true, + }, + { + proxyType: "manual", + http: "www.mozilla.org:8080", + ftp: "www.mozilla.org:8080", + ssl: "www.mozilla.org:8080", + socks: "", + httpProxyAll: true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ftp: "www.mozilla.org:8081", + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 8080, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 8081, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 8082, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 8083, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org", + ftp: "ftp://www.mozilla.org", + ssl: "https://www.mozilla.org", + socks: "mozilla.org", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 21, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 443, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 1080, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": false, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ftp: "www.mozilla.org:21", + ssl: "www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:80", + ftp: "ftp://www.mozilla.org:21", + ssl: "https://www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 21, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 443, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 1080, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": true, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ftp: "www.mozilla.org:21", + ssl: "www.mozilla.org:443", + socks: "mozilla.org:1080", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: true, + } + ); + + await testProxy( + { + proxyType: "manual", + http: "http://www.mozilla.org:80", + ftp: "ftp://www.mozilla.org:80", + ssl: "https://www.mozilla.org:80", + socks: "mozilla.org:80", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_MANUAL, + "network.proxy.http": "www.mozilla.org", + "network.proxy.http_port": 80, + "network.proxy.share_proxy_settings": false, + "network.proxy.ftp": "www.mozilla.org", + "network.proxy.ftp_port": 80, + "network.proxy.ssl": "www.mozilla.org", + "network.proxy.ssl_port": 80, + "network.proxy.socks": "mozilla.org", + "network.proxy.socks_port": 80, + "network.proxy.socks_version": 4, + "network.proxy.no_proxies_on": ".mozilla.org", + "network.http.proxy.respect-be-conservative": false, + }, + { + proxyType: "manual", + http: "www.mozilla.org:80", + httpProxyAll: false, + ftp: "www.mozilla.org:80", + ssl: "www.mozilla.org:80", + socks: "mozilla.org:80", + socksVersion: 4, + passthrough: ".mozilla.org", + respectBeConservative: false, + } + ); + + // Test resetting values. + await testProxy( + { + proxyType: "none", + http: "", + ftp: "", + ssl: "", + socks: "", + socksVersion: 5, + passthrough: "", + respectBeConservative: true, + }, + { + "network.proxy.type": proxySvc.PROXYCONFIG_DIRECT, + "network.proxy.http": "", + "network.proxy.http_port": 0, + "network.proxy.ftp": "", + "network.proxy.ftp_port": 0, + "network.proxy.ssl": "", + "network.proxy.ssl_port": 0, + "network.proxy.socks": "", + "network.proxy.socks_port": 0, + "network.proxy.socks_version": 5, + "network.proxy.no_proxies_on": "", + "network.http.proxy.respect-be-conservative": true, + } + ); + + await extension.unload(); +}); + +add_task(async function test_bad_value_proxy_config() { + let background = + AppConstants.platform === "android" + ? async () => { + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "none", + }, + }), + /proxy.settings is not supported on android/, + "proxy.settings.set rejects on Android." + ); + + await browser.test.assertRejects( + browser.proxy.settings.get({}), + /proxy.settings is not supported on android/, + "proxy.settings.get rejects on Android." + ); + + await browser.test.assertRejects( + browser.proxy.settings.clear({}), + /proxy.settings is not supported on android/, + "proxy.settings.clear rejects on Android." + ); + + browser.test.sendMessage("done"); + } + : async () => { + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "abc", + }, + }), + /abc is not a valid value for proxyType/, + "proxy.settings.set rejects with an invalid proxyType value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "autoConfig", + }, + }), + /undefined is not a valid value for autoConfigUrl/, + "proxy.settings.set for type autoConfig rejects with an empty autoConfigUrl value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "autoConfig", + autoConfigUrl: "abc", + }, + }), + /abc is not a valid value for autoConfigUrl/, + "proxy.settings.set rejects with an invalid autoConfigUrl value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "manual", + socksVersion: "abc", + }, + }), + /abc is not a valid value for socksVersion/, + "proxy.settings.set rejects with an invalid socksVersion value." + ); + + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "manual", + socksVersion: 3, + }, + }), + /3 is not a valid value for socksVersion/, + "proxy.settings.set rejects with an invalid socksVersion value." + ); + + browser.test.sendMessage("done"); + }; + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["proxy"], + }, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +// Verify proxy prefs are unset on permission removal. +add_task(async function test_proxy_settings_permissions() { + async function background() { + const permObj = { permissions: ["proxy"] }; + browser.test.onMessage.addListener(async (msg, value) => { + if (msg === "request") { + browser.test.log("requesting proxy permission"); + await browser.permissions.request(permObj); + browser.test.log("setting proxy values"); + await browser.proxy.settings.set({ value }); + browser.test.sendMessage("set"); + } else if (msg === "remove") { + await browser.permissions.remove(permObj); + browser.test.sendMessage("removed"); + } + }); + } + + let prefNames = [ + "network.proxy.type", + "network.proxy.http", + "network.proxy.http_port", + "network.proxy.ftp", + "network.proxy.ftp_port", + "network.proxy.ssl", + "network.proxy.ssl_port", + "network.proxy.socks", + "network.proxy.socks_port", + "network.proxy.socks_version", + "network.proxy.no_proxies_on", + ]; + + function checkSettings(msg, expectUserValue = false) { + info(msg); + for (let pref of prefNames) { + equal( + expectUserValue, + Services.prefs.prefHasUserValue(pref), + `${pref} set as expected ${Preferences.get(pref)}` + ); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["proxy"], + }, + incognitoOverride: "spanning", + useAddonManager: "permanent", + }); + await extension.startup(); + checkSettings("setting is not set after startup"); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("request", { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ftp: "www.mozilla.org:8081", + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + }); + await extension.awaitMessage("set"); + checkSettings("setting was set after request", true); + + extension.sendMessage("remove"); + await extension.awaitMessage("removed"); + checkSettings("setting is reset after remove"); + + // Set again to test after restart + extension.sendMessage("request", { + proxyType: "manual", + http: "www.mozilla.org:8080", + httpProxyAll: false, + ftp: "www.mozilla.org:8081", + ssl: "www.mozilla.org:8082", + socks: "mozilla.org:8083", + socksVersion: 4, + passthrough: ".mozilla.org", + }); + await extension.awaitMessage("set"); + checkSettings("setting was set after request", true); + }); + + // force the permissions store to be re-read on startup + await ExtensionPermissions._uninit(); + resetHandlingUserInput(); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + await withHandlingUserInput(extension, async () => { + extension.sendMessage("remove"); + await extension.awaitMessage("removed"); + checkSettings("setting is reset after remove"); + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js new file mode 100644 index 0000000000..db041d20d0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_onauthrequired.js @@ -0,0 +1,302 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "authManager", + "@mozilla.org/network/http-auth-manager;1", + "nsIHttpAuthManager" +); + +const proxy = createHttpServer(); + +// accept proxy connections for mozilla.org +proxy.identity.add("http", "mozilla.org", 80); +proxy.identity.add("https", "407.example.com", 443); + +proxy.registerPathHandler("CONNECT", (request, response) => { + Assert.equal(request.method, "CONNECT"); + switch (request.host) { + case "407.example.com": + response.setStatusLine(request.httpVersion, 407, "Authenticate"); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false); + response.write("auth required"); + break; + default: + response.setStatusLine(request.httpVersion, 500, "I am dumb"); + } +}); + +proxy.registerPathHandler("/", (request, response) => { + if (request.hasHeader("Proxy-Authorization")) { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + response.write("ok, got proxy auth"); + } else { + response.setStatusLine( + request.httpVersion, + 407, + "Proxy authentication required" + ); + response.setHeader("Content-Type", "text/plain", false); + response.setHeader("Proxy-Authenticate", 'Basic realm="foobar"', false); + response.write("auth required"); + } +}); + +function getExtension(background) { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${proxy.identity.primaryPort})`, + }); +} + +add_task(async function test_webRequest_auth_proxy() { + async function background(port) { + let expecting = [ + "onBeforeSendHeaders", + "onSendHeaders", + "onAuthRequired", + "onBeforeSendHeaders", + "onSendHeaders", + "onCompleted", + ]; + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onBeforeSendHeaders", + expecting.shift(), + "got expected event" + ); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set" + ); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onSendHeaders.addListener( + details => { + browser.test.log(`onSendHeaders ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onSendHeaders", + expecting.shift(), + "got expected event" + ); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log(`onAuthRequired ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onAuthRequired", + expecting.shift(), + "got expected event" + ); + browser.test.assertTrue(details.isProxy, "proxied request"); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "localhost", + details.challenger.host, + "proxy host" + ); + browser.test.assertEq(port, details.challenger.port, "proxy port"); + return { authCredentials: { username: "puser", password: "ppass" } }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + browser.test.assertEq( + "onCompleted", + expecting.shift(), + "got expected event" + ); + browser.test.assertEq( + "localhost", + details.proxyInfo.host, + "proxy host" + ); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("http", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "", + details.proxyInfo.username, + "proxy username not set by onAuthRequired" + ); + browser.test.assertEq( + undefined, + details.proxyInfo.password, + "no proxy password" + ); + browser.test.assertEq(expecting.length, 0, "got all expected events"); + browser.test.sendMessage("done"); + }, + { urls: ["<all_urls>"] } + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [{ host: "localhost", port, type: "http" }]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://mozilla.org/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); + +add_task(async function test_webRequest_auth_proxy_https() { + async function background(port) { + let authReceived = false; + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + if (authReceived) { + browser.test.sendMessage("done"); + return { cancel: true }; + } + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + authReceived = true; + return { authCredentials: { username: "puser", password: "ppass" } }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return [{ host: "localhost", port, type: "http" }]; + }, + { urls: ["<all_urls>"] }, + ["requestHeaders"] + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `https://407.example.com/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); + +add_task(async function test_webRequest_auth_proxy_system() { + async function background(port) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail("onBeforeRequest"); + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.sendMessage("onAuthRequired"); + // cancel is silently ignored, if it were not (e.g someone messes up in + // WebRequest.jsm and allows cancel) this test would fail. + return { + cancel: true, + authCredentials: { username: "puser", password: "ppass" }, + }; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + return { host: "localhost", port, type: "http" }; + }, + { urls: ["<all_urls>"] } + ); + browser.test.sendMessage("ready"); + } + + let handlingExt = getExtension(background); + + await handlingExt.startup(); + await handlingExt.awaitMessage("ready"); + + authManager.clearAll(); + + function fetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.mozBackgroundRequest = true; + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = () => { + reject(xhr.status); + }; + xhr.send(); + }); + } + + await Promise.all([ + handlingExt.awaitMessage("onAuthRequired"), + fetch("http://mozilla.org"), + ]); + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js new file mode 100644 index 0000000000..281804dccb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_settings.js @@ -0,0 +1,107 @@ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "HttpServer", + "resource://testing-common/httpd.js" +); + +const { + createAppInfo, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +// We cannot use createHttpServer because it also messes with proxies. We want +// httpChannel to pick up the prefs we set and use those to proxy to our server. +// If this were to fail, we would get an error about making a request out to +// the network. +const proxy = new HttpServer(); +proxy.start(-1); +proxy.registerPathHandler("/fubar", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); +registerCleanupFunction(() => { + return new Promise(resolve => { + proxy.stop(resolve); + }); +}); + +add_task(async function test_proxy_settings() { + async function background(host, port) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + host, + details.proxyInfo.host, + "proxy host matched" + ); + browser.test.assertEq( + port, + details.proxyInfo.port, + "proxy port matched" + ); + }, + { urls: ["http://example.com/*"] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.notifyPass("proxytest"); + }, + { urls: ["http://example.com/*"] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.notifyFail("proxytest"); + }, + { urls: ["http://example.com/*"] } + ); + + // Wait for the settings before testing a request. + await browser.proxy.settings.set({ + value: { + proxyType: "manual", + http: `${host}:${port}`, + }, + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "proxy.settings@mochi.test" } }, + permissions: ["proxy", "webRequest", "<all_urls>"], + }, + incognitoOverride: "spanning", + useAddonManager: "temporary", + background: `(${background})("${proxy.identity.primaryHost}", ${proxy.identity.primaryPort})`, + }); + + await promiseStartupManager(); + await extension.startup(); + await extension.awaitMessage("ready"); + equal( + Services.prefs.getStringPref("network.proxy.http"), + proxy.identity.primaryHost, + "proxy address is set" + ); + equal( + Services.prefs.getIntPref("network.proxy.http_port"), + proxy.identity.primaryPort, + "proxy port is set" + ); + let ok = extension.awaitFinish("proxytest"); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/fubar" + ); + await ok; + + await contentPage.close(); + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js new file mode 100644 index 0000000000..62436737f1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_socks.js @@ -0,0 +1,557 @@ +"use strict"; + +/* globals TCPServerSocket */ + +const CC = Components.Constructor; + +const BinaryInputStream = CC( + "@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream" +); + +const currentThread = Cc["@mozilla.org/thread-manager;1"].getService() + .currentThread; + +// Most of the socks logic here is copied and upgraded to support authentication +// for socks5. The original test is from netwerk/test/unit/test_socks.js + +// Socks 4 support was left in place for future tests. + +const STATE_WAIT_GREETING = 1; +const STATE_WAIT_SOCKS4_REQUEST = 2; +const STATE_WAIT_SOCKS4_USERNAME = 3; +const STATE_WAIT_SOCKS4_HOSTNAME = 4; +const STATE_WAIT_SOCKS5_GREETING = 5; +const STATE_WAIT_SOCKS5_REQUEST = 6; +const STATE_WAIT_SOCKS5_AUTH = 7; +const STATE_WAIT_INPUT = 8; +const STATE_FINISHED = 9; + +/** + * A basic socks proxy setup that handles a single http response page. This + * is used for testing socks auth with webrequest. We don't bother making + * sure we buffer ondata, etc., we'll never get anything but tiny chunks here. + */ +class SocksClient { + constructor(server, socket) { + this.server = server; + this.type = ""; + this.username = ""; + this.dest_name = ""; + this.dest_addr = []; + this.dest_port = []; + + this.inbuf = []; + this.state = STATE_WAIT_GREETING; + this.socket = socket; + + socket.onclose = event => { + this.server.requestCompleted(this); + }; + socket.ondata = event => { + let len = event.data.byteLength; + + if (len == 0 && this.state == STATE_FINISHED) { + this.close(); + this.server.requestCompleted(this); + return; + } + + this.inbuf = new Uint8Array(event.data); + Promise.resolve().then(() => { + this.callState(); + }); + }; + } + + callState() { + switch (this.state) { + case STATE_WAIT_GREETING: + this.checkSocksGreeting(); + break; + case STATE_WAIT_SOCKS4_REQUEST: + this.checkSocks4Request(); + break; + case STATE_WAIT_SOCKS4_USERNAME: + this.checkSocks4Username(); + break; + case STATE_WAIT_SOCKS4_HOSTNAME: + this.checkSocks4Hostname(); + break; + case STATE_WAIT_SOCKS5_GREETING: + this.checkSocks5Greeting(); + break; + case STATE_WAIT_SOCKS5_REQUEST: + this.checkSocks5Request(); + break; + case STATE_WAIT_SOCKS5_AUTH: + this.checkSocks5Auth(); + break; + case STATE_WAIT_INPUT: + this.checkRequest(); + break; + default: + do_throw("server: read in invalid state!"); + } + } + + write(buf) { + this.socket.send(new Uint8Array(buf).buffer); + } + + checkSocksGreeting() { + if (!this.inbuf.length) { + return; + } + + if (this.inbuf[0] == 4) { + this.type = "socks4"; + this.state = STATE_WAIT_SOCKS4_REQUEST; + this.checkSocks4Request(); + } else if (this.inbuf[0] == 5) { + this.type = "socks"; + this.state = STATE_WAIT_SOCKS5_GREETING; + this.checkSocks5Greeting(); + } else { + do_throw("Unknown socks protocol!"); + } + } + + checkSocks4Request() { + if (this.inbuf.length < 8) { + return; + } + + this.dest_port = this.inbuf.slice(2, 4); + this.dest_addr = this.inbuf.slice(4, 8); + + this.inbuf = this.inbuf.slice(8); + this.state = STATE_WAIT_SOCKS4_USERNAME; + this.checkSocks4Username(); + } + + readString() { + let i = this.inbuf.indexOf(0); + let str = null; + + if (i >= 0) { + let decoder = new TextDecoder(); + str = decoder.decode(this.inbuf.slice(0, i)); + this.inbuf = this.inbuf.slice(i + 1); + } + + return str; + } + + checkSocks4Username() { + let str = this.readString(); + + if (str == null) { + return; + } + + this.username = str; + if ( + this.dest_addr[0] == 0 && + this.dest_addr[1] == 0 && + this.dest_addr[2] == 0 && + this.dest_addr[3] != 0 + ) { + this.state = STATE_WAIT_SOCKS4_HOSTNAME; + this.checkSocks4Hostname(); + } else { + this.sendSocks4Response(); + } + } + + checkSocks4Hostname() { + let str = this.readString(); + + if (str == null) { + return; + } + + this.dest_name = str; + this.sendSocks4Response(); + } + + sendSocks4Response() { + this.state = STATE_WAIT_INPUT; + this.inbuf = []; + this.write([0, 0x5a, 0, 0, 0, 0, 0, 0]); + } + + /** + * checks authentication information. + * + * buf[0] socks version + * buf[1] number of auth methods supported + * buf[2+nmethods] value for each auth method + * + * Response is + * byte[0] socks version + * byte[1] desired auth method + * + * For whatever reason, Firefox does not present auth method 0x02 however + * responding with that does cause Firefox to send authentication if + * the nsIProxyInfo instance has the data. IUUC Firefox should send + * supported methods, but I'm no socks expert. + */ + checkSocks5Greeting() { + if (this.inbuf.length < 2) { + return; + } + let nmethods = this.inbuf[1]; + if (this.inbuf.length < 2 + nmethods) { + return; + } + + // See comment above, keeping for future update. + // let methods = this.inbuf.slice(2, 2 + nmethods); + + this.inbuf = []; + if (this.server.password || this.server.username) { + this.state = STATE_WAIT_SOCKS5_AUTH; + this.write([5, 2]); + } else { + this.state = STATE_WAIT_SOCKS5_REQUEST; + this.write([5, 0]); + } + } + + checkSocks5Auth() { + equal(this.inbuf[0], 0x01, "subnegotiation version"); + let uname_len = this.inbuf[1]; + let pass_len = this.inbuf[2 + uname_len]; + let unnamebuf = this.inbuf.slice(2, 2 + uname_len); + let pass_start = 2 + uname_len + 1; + let pwordbuf = this.inbuf.slice(pass_start, pass_start + pass_len); + let decoder = new TextDecoder(); + let username = decoder.decode(unnamebuf); + let password = decoder.decode(pwordbuf); + this.inbuf = []; + equal(username, this.server.username, "socks auth username"); + equal(password, this.server.password, "socks auth password"); + if (username == this.server.username && password == this.server.password) { + this.state = STATE_WAIT_SOCKS5_REQUEST; + // x00 is success, any other value closes the connection + this.write([1, 0]); + return; + } + this.state = STATE_FINISHED; + this.write([1, 1]); + } + + checkSocks5Request() { + if (this.inbuf.length < 4) { + return; + } + + let atype = this.inbuf[3]; + let len; + let name = false; + + switch (atype) { + case 0x01: + len = 4; + break; + case 0x03: + len = this.inbuf[4]; + name = true; + break; + case 0x04: + len = 16; + break; + default: + do_throw("Unknown address type " + atype); + } + + if (name) { + if (this.inbuf.length < 4 + len + 1 + 2) { + return; + } + + let buf = this.inbuf.slice(5, 5 + len); + let decoder = new TextDecoder(); + this.dest_name = decoder.decode(buf); + len += 1; + } else { + if (this.inbuf.length < 4 + len + 2) { + return; + } + + this.dest_addr = this.inbuf.slice(4, 4 + len); + } + + len += 4; + this.dest_port = this.inbuf.slice(len, len + 2); + this.inbuf = this.inbuf.slice(len + 2); + this.sendSocks5Response(); + } + + sendSocks5Response() { + let buf; + if (this.dest_addr.length == 16) { + // send a successful response with the address, [::1]:80 + buf = [5, 0, 0, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 80]; + } else { + // send a successful response with the address, 127.0.0.1:80 + buf = [5, 0, 0, 1, 127, 0, 0, 1, 0, 80]; + } + this.state = STATE_WAIT_INPUT; + this.inbuf = []; + this.write(buf); + } + + checkRequest() { + let decoder = new TextDecoder(); + let request = decoder.decode(this.inbuf); + + if (request == "PING!") { + this.state = STATE_FINISHED; + this.socket.send("PONG!"); + } else if (request.startsWith("GET / HTTP/1.1")) { + this.socket.send( + "HTTP/1.1 200 OK\r\n" + + "Content-Length: 2\r\n" + + "Content-Type: text/html\r\n" + + "\r\nOK" + ); + this.state = STATE_FINISHED; + } + } + + close() { + this.socket.close(); + } +} + +class SocksTestServer { + constructor() { + this.client_connections = new Set(); + this.listener = new TCPServerSocket(-1, { binaryType: "arraybuffer" }, -1); + this.listener.onconnect = event => { + let client = new SocksClient(this, event.socket); + this.client_connections.add(client); + }; + } + + requestCompleted(client) { + this.client_connections.delete(client); + } + + close() { + for (let client of this.client_connections) { + client.close(); + } + this.client_connections = new Set(); + if (this.listener) { + this.listener.close(); + this.listener = null; + } + } + + setUserPass(username, password) { + this.username = username; + this.password = password; + } +} + +/** + * Tests the basic socks logic using a simple socket connection and the + * protocol proxy service. It seems TCPSocket has no way to tie proxy + * data to it, so we go old school here. + */ +class SocksTestClient { + constructor(socks, dest, resolve, reject) { + let pps = Cc["@mozilla.org/network/protocol-proxy-service;1"].getService( + Ci.nsIProtocolProxyService + ); + let sts = Cc["@mozilla.org/network/socket-transport-service;1"].getService( + Ci.nsISocketTransportService + ); + + let pi_flags = 0; + if (socks.dns == "remote") { + pi_flags = Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + } + + let pi = pps.newProxyInfoWithAuth( + socks.version, + socks.host, + socks.port, + socks.username, + socks.password, + "", + "", + pi_flags, + -1, + null + ); + + this.trans = sts.createTransport([], dest.host, dest.port, pi); + this.input = this.trans.openInputStream( + Ci.nsITransport.OPEN_BLOCKING, + 0, + 0 + ); + this.output = this.trans.openOutputStream( + Ci.nsITransport.OPEN_BLOCKING, + 0, + 0 + ); + this.outbuf = String(); + this.resolve = resolve; + this.reject = reject; + + this.write("PING!"); + this.input.asyncWait(this, 0, 0, currentThread); + } + + onInputStreamReady(stream) { + let len = 0; + try { + len = stream.available(); + } catch (e) { + // This will happen on auth failure. + this.reject(e); + return; + } + let bin = new BinaryInputStream(stream); + let data = bin.readByteArray(len); + let decoder = new TextDecoder(); + let result = decoder.decode(data); + if (result == "PONG!") { + this.resolve(result); + } else { + this.reject(); + } + } + + write(buf) { + this.outbuf += buf; + this.output.asyncWait(this, 0, 0, currentThread); + } + + onOutputStreamReady(stream) { + let len = stream.write(this.outbuf, this.outbuf.length); + if (len != this.outbuf.length) { + this.outbuf = this.outbuf.substring(len); + stream.asyncWait(this, 0, 0, currentThread); + } else { + this.outbuf = String(); + } + } + + close() { + this.output.close(); + } +} + +const socksServer = new SocksTestServer(); +socksServer.setUserPass("foo", "bar"); +registerCleanupFunction(() => { + socksServer.close(); +}); + +// A simple ping/pong to test the socks server. +add_task(async function test_socks_server() { + let socks = { + version: "socks", + host: "127.0.0.1", + port: socksServer.listener.localPort, + username: "foo", + password: "bar", + dns: false, + }; + let dest = { + host: "localhost", + port: 8888, + }; + + new Promise((resolve, reject) => { + new SocksTestClient(socks, dest, resolve, reject); + }) + .then(result => { + equal("PONG!", result, "socks test ok"); + }) + .catch(result => { + ok(false, `socks test failed ${result}`); + }); +}); + +add_task(async function test_webRequest_socks_proxy() { + async function background(port) { + function checkProxyData(details) { + browser.test.assertEq("127.0.0.1", details.proxyInfo.host, "proxy host"); + browser.test.assertEq(port, details.proxyInfo.port, "proxy port"); + browser.test.assertEq("socks", details.proxyInfo.type, "proxy type"); + browser.test.assertEq( + "foo", + details.proxyInfo.username, + "proxy username not set" + ); + browser.test.assertEq( + undefined, + details.proxyInfo.password, + "no proxy password passed to webrequest" + ); + } + browser.webRequest.onBeforeRequest.addListener( + details => { + checkProxyData(details); + }, + { urls: ["<all_urls>"] } + ); + browser.webRequest.onAuthRequired.addListener( + details => { + // We should never get onAuthRequired for socks proxy + browser.test.fail("onAuthRequired"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + browser.webRequest.onCompleted.addListener( + details => { + checkProxyData(details); + browser.test.sendMessage("done"); + }, + { urls: ["<all_urls>"] } + ); + browser.proxy.onRequest.addListener( + () => { + return [ + { + type: "socks", + host: "127.0.0.1", + port, + username: "foo", + password: "bar", + }, + ]; + }, + { urls: ["<all_urls>"] } + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${socksServer.listener.localPort})`, + }); + + // proxy.register is deprecated - bug 1443259. + ExtensionTestUtils.failOnSchemaWarnings(false); + await handlingExt.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://localhost/` + ); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js new file mode 100644 index 0000000000..01f864cb7a --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_speculative.js @@ -0,0 +1,52 @@ +"use strict"; + +const { ExtensionUtils } = ChromeUtils.import( + "resource://gre/modules/ExtensionUtils.jsm" +); + +const proxy = createHttpServer(); + +add_task(async function test_speculative_connect() { + function background() { + // Handle the proxy request. + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + browser.test.assertEq( + details.type, + "speculative", + "Should have seen a speculative proxy request." + ); + return [{ type: "direct" }]; + }, + { urls: ["<all_urls>"], types: ["speculative"] } + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})()`, + }); + + Services.prefs.setBoolPref("network.http.debug-observations", true); + + await handlingExt.startup(); + + let notificationPromise = ExtensionUtils.promiseObserved( + "speculative-connect-request" + ); + + let uri = Services.io.newURI( + `http://${proxy.identity.primaryHost}:${proxy.identity.primaryPort}` + ); + Services.io.speculativeConnect( + uri, + Services.scriptSecurityManager.getSystemPrincipal(), + null + ); + await notificationPromise; + + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js new file mode 100644 index 0000000000..8d0f98f308 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_proxy_startup.js @@ -0,0 +1,158 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +let nonProxiedRequests = 0; +const nonProxiedServer = createHttpServer({ hosts: ["example.com"] }); +nonProxiedServer.registerPathHandler("/", (request, response) => { + nonProxiedRequests++; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +// No hosts defined to avoid proxy filter setup. +let proxiedRequests = 0; +const server = createHttpServer(); +server.identity.add("http", "proxied.example.com", 80); +server.registerPathHandler("/", (request, response) => { + proxiedRequests++; + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +function promiseExtensionEvent(wrapper, event) { + return new Promise(resolve => { + wrapper.extension.once(event, resolve); + }); +} + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-page-event", "start-background-page"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +// Test that a proxy listener during startup does not immediately +// start the background page, but the event is queued until the background +// page is started. +add_task(async function test_proxy_startup() { + await promiseStartupManager(); + + function background(proxyInfo) { + browser.proxy.onRequest.addListener( + details => { + // ignore speculative requests + if (details.type == "xmlhttprequest") { + browser.test.sendMessage("saw-request"); + } + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + } + + let proxyInfo = { + host: server.identity.primaryHost, + port: server.identity.primaryPort, + type: "http", + }; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["proxy", "http://proxied.example.com/*"], + }, + background: `(${background})(${JSON.stringify(proxyInfo)})`, + }); + + await extension.startup(); + + // Initial requests to test the proxy and non-proxied servers. + await Promise.all([ + extension.awaitMessage("saw-request"), + ExtensionTestUtils.fetch("http://proxied.example.com/?a=0"), + ]); + equal(1, proxiedRequests, "proxied request ok"); + equal(0, nonProxiedRequests, "non proxied request ok"); + + await ExtensionTestUtils.fetch("http://example.com/?a=0"); + equal(1, proxiedRequests, "proxied request ok"); + equal(1, nonProxiedRequests, "non proxied request ok"); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let events = trackEvents(extension); + + // Initiate a non-proxied request to make sure the startup listeners are using + // the extensions filters/etc. + await ExtensionTestUtils.fetch("http://example.com/?a=1"); + equal(1, proxiedRequests, "proxied request ok"); + equal(2, nonProxiedRequests, "non proxied request ok"); + + equal( + events.get("background-page-event"), + false, + "Should not have gotten a background page event" + ); + + // Make a request that the extension will proxy once it is started. + let request = Promise.all([ + extension.awaitMessage("saw-request"), + ExtensionTestUtils.fetch("http://proxied.example.com/?a=1"), + ]); + + await promiseExtensionEvent(extension, "background-page-event"); + equal( + events.get("background-page-event"), + true, + "Should have gotten a background page event" + ); + + // Test the background page startup. + equal( + events.get("start-background-page"), + false, + "Should have gotten a background page event" + ); + + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await new Promise(executeSoon); + + equal( + events.get("start-background-page"), + true, + "Should have gotten a background page event" + ); + + // Verify our proxied request finishes properly and that the + // request was not handled via our non-proxied server. + await request; + equal(2, proxiedRequests, "proxied request ok"); + equal(2, nonProxiedRequests, "non proxied requests ok"); + + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js new file mode 100644 index 0000000000..4c8175e0c0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_redirects.js @@ -0,0 +1,567 @@ +"use strict"; + +// Tests whether we can redirect to a moz-extension: url. +ChromeUtils.defineModuleGetter( + this, + "TestUtils", + "resource://testing-common/TestUtils.jsm" +); + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +server.registerPathHandler("/redirect", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", params.get("redirect_uri")); + response.write("redirecting"); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +function onStopListener(channel) { + return new Promise(resolve => { + let orig = channel.QueryInterface(Ci.nsITraceableChannel).setNewListener({ + QueryInterface: ChromeUtils.generateQI([ + "nsIRequestObserver", + "nsIStreamListener", + ]), + getFinalURI(request) { + let { loadInfo } = request; + return (loadInfo && loadInfo.resultPrincipalURI) || request.originalURI; + }, + onDataAvailable(...args) { + orig.onDataAvailable(...args); + }, + onStartRequest(request) { + orig.onStartRequest(request); + }, + onStopRequest(request, statusCode) { + orig.onStopRequest(request, statusCode); + let URI = this.getFinalURI(request.QueryInterface(Ci.nsIChannel)); + resolve(URI && URI.spec); + }, + }); + }); +} + +async function onModifyListener(originUrl, redirectToUrl) { + return TestUtils.topicObserved("http-on-modify-request", (subject, data) => { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + return channel.URI && channel.URI.spec == originUrl; + }).then(([subject, data]) => { + let channel = subject.QueryInterface(Ci.nsIHttpChannel); + if (redirectToUrl) { + channel.redirectTo(Services.io.newURI(redirectToUrl)); + } + return channel; + }); +} + +function getExtension( + accessible = false, + background = undefined, + blocking = true +) { + let manifest = { + permissions: ["webRequest", "<all_urls>"], + }; + if (blocking) { + manifest.permissions.push("webRequestBlocking"); + } + if (accessible) { + manifest.web_accessible_resources = ["finished.html"]; + } + if (!background) { + background = () => { + // send the extensions public uri to the test. + let exturi = browser.extension.getURL("finished.html"); + browser.test.sendMessage("redirectURI", exturi); + }; + } + return ExtensionTestUtils.loadExtension({ + manifest, + files: { + "finished.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>redirected!</h1> + </body> + </html> + `.trim(), + }, + background, + }); +} + +async function redirection_test(url, channelRedirectUrl) { + // setup our observer + let watcher = onModifyListener(url, channelRedirectUrl).then(channel => { + return onStopListener(channel); + }); + let xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.send(); + return watcher; +} + +// This test verifies failure without web_accessible_resources. +add_task(async function test_redirect_to_non_accessible_resource() { + let extension = getExtension(); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let result = await redirection_test(url); + equal(result, url, `expected no redirect`); + await extension.unload(); +}); + +// This test makes a request against a server that redirects with a 302. +add_task(async function test_302_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let result = await redirection_test(url); + equal(result, redirectUrl, "redirect request is finished"); + await extension.unload(); +}); + +// This test uses channel.redirectTo during http-on-modify to redirect to the +// moz-extension url. +add_task(async function test_channel_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + let result = await redirection_test(url, redirectUrl); + equal(result, redirectUrl, "redirect request is finished"); + await extension.unload(); +}); + +// This test verifies failure without web_accessible_resources. +add_task(async function test_content_redirect_to_non_accessible_resource() { + let extension = getExtension(); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let watcher = onModifyListener(url).then(channel => { + return onStopListener(channel); + }); + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl: "about:blank", + }); + equal( + contentPage.browser.documentURI.spec, + "about:blank", + `expected no redirect` + ); + equal(await watcher, url, "expected no redirect"); + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server that redirects with a 302. +add_task(async function test_content_302_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/redirect?redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await contentPage.close(); + await extension.unload(); +}); + +// This test uses channel.redirectTo during http-on-modify to redirect to the +// moz-extension url. +add_task(async function test_content_channel_redirect_to_extension() { + let extension = getExtension(true); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + onModifyListener(url, redirectUrl); + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page. +add_task(async function test_extension_302_redirect_web() { + function background(serverUrl) { + let expectedUrls = ["/redirect", "/dummy"]; + let expected = [ + "onBeforeRequest", + "onHeadersReceived", + "onBeforeRedirect", + "onBeforeRequest", + "onHeadersReceived", + "onResponseStarted", + "onCompleted", + ]; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertTrue( + details.url.includes(expectedUrls.shift()), + "onBeforeRequest url matches" + ); + browser.test.assertEq( + expected.shift(), + "onBeforeRequest", + "onBeforeRequest matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.assertEq( + expected.shift(), + "onHeadersReceived", + "onHeadersReceived matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onResponseStarted.addListener( + details => { + browser.test.assertEq( + expected.shift(), + "onResponseStarted", + "onResponseStarted matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertTrue( + details.redirectUrl.includes("/dummy"), + "onBeforeRedirect matches redirectUrl" + ); + browser.test.assertEq( + expected.shift(), + "onBeforeRedirect", + "onBeforeRedirect matches" + ); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertTrue( + details.url.includes("/dummy"), + "onCompleted expected url received" + ); + browser.test.assertEq( + expected.shift(), + "onCompleted", + "onCompleted matches" + ); + browser.test.notifyPass("requestCompleted"); + }, + { urls: [serverUrl] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*")`, + false + ); + await extension.startup(); + let redirectUrl = `${gServerUrl}/dummy`; + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_opening() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onBeforeRequest", + url: `${gServerUrl}/redirect`, + }, + { + event: "onBeforeRequest", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onBeforeRequest.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onBeforeRequest", + "onBeforeRequest event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onBeforeRequest url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_modify() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onHeadersReceived", + url: `${gServerUrl}/redirect`, + }, + { + event: "onHeadersReceived", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onHeadersReceived.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onHeadersReceived", + "onHeadersReceived event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onHeadersReceived url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: ["<all_urls>"] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests redirect to another server page, without +// onBeforeRedirect. Bug 1448599 +add_task(async function test_extension_302_redirect_tracing() { + let redirectUrl = `${gServerUrl}/dummy`; + let expectData = [ + { + event: "onCompleted", + url: redirectUrl, + }, + ]; + function background(serverUrl, expected) { + browser.webRequest.onCompleted.addListener( + details => { + let expect = expected.shift(); + browser.test.assertEq( + expect.event, + "onCompleted", + "onCompleted event matches" + ); + browser.test.assertTrue( + details.url.startsWith(expect.url), + "onCompleted url matches" + ); + if (expected.length === 0) { + browser.test.notifyPass("requestCompleted"); + } + }, + { urls: [serverUrl] } + ); + } + let extension = getExtension( + false, + `(${background})("*://${server.identity.primaryHost}/*", ${JSON.stringify( + expectData + )})`, + false + ); + await extension.startup(); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}); + +// This test makes a request against a server and tests webrequest. Currently +// disabled due to NS_BINDING_ABORTED happening. +add_task(async function test_extension_302_redirect() { + let extension = getExtension(true, () => { + let myuri = browser.extension.getURL("*"); + let exturi = browser.extension.getURL("finished.html"); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertEq(details.redirectUrl, exturi, "redirect matches"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertEq(details.url, exturi, "expected url received"); + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + // send the extensions public uri to the test. + browser.test.sendMessage("redirectURI", exturi); + }); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/redirect?r=${Math.random()}&redirect_uri=${redirectUrl}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal( + contentPage.browser.documentURI.spec, + redirectUrl, + `expected content redirect` + ); + await completed; + await contentPage.close(); + await extension.unload(); +}).skip(); + +// This test makes a request and uses onBeforeRequet to redirect to moz-ext. +// Currently disabled due to NS_BINDING_ABORTED happening. +add_task(async function test_extension_redirect() { + let extension = getExtension(true, () => { + let myuri = browser.extension.getURL("*"); + let exturi = browser.extension.getURL("finished.html"); + browser.webRequest.onBeforeRequest.addListener( + details => { + return { redirectUrl: exturi }; + }, + { urls: ["<all_urls>", myuri] }, + ["blocking"] + ); + browser.webRequest.onBeforeRedirect.addListener( + details => { + browser.test.assertEq(details.redirectUrl, exturi, "redirect matches"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.assertEq(details.url, exturi, "expected url received"); + browser.test.notifyPass("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred ${JSON.stringify(details)}`); + browser.test.notifyFail("requestCompleted"); + }, + { urls: ["<all_urls>", myuri] } + ); + // send the extensions public uri to the test. + browser.test.sendMessage("redirectURI", exturi); + }); + await extension.startup(); + let redirectUrl = await extension.awaitMessage("redirectURI"); + let completed = extension.awaitFinish("requestCompleted"); + let url = `${gServerUrl}/dummy?r=${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + redirectUrl, + }); + equal(contentPage.browser.documentURI.spec, redirectUrl, `expected redirect`); + await completed; + await contentPage.close(); + await extension.unload(); +}).skip(); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js new file mode 100644 index 0000000000..e42f45c019 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_connect_no_receiver.js @@ -0,0 +1,26 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_connect_without_listener() { + function background() { + let port = browser.runtime.connect(); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + port.error && port.error.message + ); + browser.test.notifyPass("port.onDisconnect was called"); + }); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("port.onDisconnect was called"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js new file mode 100644 index 0000000000..3f3b8f8e95 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getBrowserInfo.js @@ -0,0 +1,26 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +add_task(async function setup() { + ExtensionTestUtils.mockAppInfo(); +}); + +add_task(async function test_getBrowserInfo() { + async function background() { + let info = await browser.runtime.getBrowserInfo(); + + browser.test.assertEq(info.name, "XPCShell", "name is valid"); + browser.test.assertEq(info.vendor, "Mozilla", "vendor is Mozilla"); + browser.test.assertEq(info.version, "48", "version is correct"); + browser.test.assertEq(info.buildID, "20160315", "buildID is correct"); + + browser.test.notifyPass("runtime.getBrowserInfo"); + } + + const extension = ExtensionTestUtils.loadExtension({ background }); + await extension.startup(); + await extension.awaitFinish("runtime.getBrowserInfo"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js new file mode 100644 index 0000000000..8f213b0dec --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_getPlatformInfo.js @@ -0,0 +1,36 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function backgroundScript() { + browser.runtime.getPlatformInfo(info => { + let validOSs = ["mac", "win", "android", "cros", "linux", "openbsd"]; + let validArchs = [ + "aarch64", + "arm", + "ppc64", + "s390x", + "sparc64", + "x86-32", + "x86-64", + ]; + + browser.test.assertTrue(validOSs.includes(info.os), "OS is valid"); + browser.test.assertTrue( + validArchs.includes(info.arch), + "Architecture is valid" + ); + browser.test.notifyPass("runtime.getPlatformInfo"); + }); +} + +let extensionData = { + background: backgroundScript, +}; + +add_task(async function() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitFinish("runtime.getPlatformInfo"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js new file mode 100644 index 0000000000..6967e81232 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_id.js @@ -0,0 +1,46 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_runtime_id() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + }, + + background() { + browser.test.sendMessage("background-id", browser.runtime.id); + }, + + files: { + "content_script.js"() { + browser.test.sendMessage("content-id", browser.runtime.id); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + let backgroundId = await extension.awaitMessage("background-id"); + equal( + backgroundId, + extension.id, + "runtime.id from background script is correct" + ); + + let contentId = await extension.awaitMessage("content-id"); + equal(contentId, extension.id, "runtime.id from content script is correct"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js new file mode 100644 index 0000000000..6d71758a38 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_messaging_self.js @@ -0,0 +1,84 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task( + async function test_messaging_to_self_should_not_trigger_onMessage_onConnect() { + async function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("msg from child", msg); + browser.test.sendMessage( + "sendMessage did not call same-frame onMessage" + ); + }); + + browser.test.onMessage.addListener(msg => { + browser.test.assertEq( + "sendMessage with a listener in another frame", + msg + ); + browser.runtime.sendMessage("should only reach another frame"); + }); + + await browser.test.assertRejects( + browser.runtime.sendMessage("should not trigger same-frame onMessage"), + "Could not establish connection. Receiving end does not exist." + ); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("from-frame", port.name); + browser.runtime.connect({ name: "from-bg-2" }); + }); + + await new Promise(resolve => { + let port = browser.runtime.connect({ name: "from-bg-1" }); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + port.error.message + ); + resolve(); + }); + }); + + let anotherFrame = document.createElement("iframe"); + anotherFrame.src = browser.extension.getURL("extensionpage.html"); + document.body.appendChild(anotherFrame); + } + + function lastScript() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq("should only reach another frame", msg); + browser.runtime.sendMessage("msg from child"); + }); + browser.test.sendMessage("sendMessage callback called"); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("from-bg-2", port.name); + browser.test.sendMessage("connect did not call same-frame onConnect"); + }); + browser.runtime.connect({ name: "from-frame" }); + } + + let extensionData = { + background, + files: { + "lastScript.js": lastScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="lastScript.js"></script>`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitMessage("sendMessage callback called"); + extension.sendMessage("sendMessage with a listener in another frame"); + + await Promise.all([ + extension.awaitMessage("connect did not call same-frame onConnect"), + extension.awaitMessage("sendMessage did not call same-frame onMessage"), + ]); + + await extension.unload(); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js new file mode 100644 index 0000000000..7c54389b39 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_onInstalled_and_onStartup.js @@ -0,0 +1,401 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseAddonEvent, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +// Ensure that the background page is automatically started after using +// promiseStartupManager. +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +function background() { + let onInstalledDetails = null; + let onStartupFired = false; + + browser.runtime.onInstalled.addListener(details => { + onInstalledDetails = details; + }); + + browser.runtime.onStartup.addListener(() => { + onStartupFired = true; + }); + + browser.test.onMessage.addListener(message => { + if (message === "get-on-installed-details") { + onInstalledDetails = onInstalledDetails || { fired: false }; + browser.test.sendMessage("on-installed-details", onInstalledDetails); + } else if (message === "did-on-startup-fire") { + browser.test.sendMessage("on-startup-fired", onStartupFired); + } else if (message === "reload-extension") { + browser.runtime.reload(); + } + }); + + browser.runtime.onUpdateAvailable.addListener(details => { + browser.test.sendMessage("reloading"); + browser.runtime.reload(); + }); +} + +async function expectEvents( + extension, + { + onStartupFired, + onInstalledFired, + onInstalledReason, + onInstalledTemporary, + onInstalledPrevious, + } +) { + extension.sendMessage("get-on-installed-details"); + let details = await extension.awaitMessage("on-installed-details"); + if (onInstalledFired) { + equal( + details.reason, + onInstalledReason, + "runtime.onInstalled fired with the correct reason" + ); + equal( + details.temporary, + onInstalledTemporary, + "runtime.onInstalled fired with the correct temporary flag" + ); + if (onInstalledPrevious) { + equal( + details.previousVersion, + onInstalledPrevious, + "runtime.onInstalled after update with correct previousVersion" + ); + } + } else { + equal( + details.fired, + onInstalledFired, + "runtime.onInstalled should not have fired" + ); + } + + extension.sendMessage("did-on-startup-fire"); + let fired = await extension.awaitMessage("on-startup-fired"); + equal( + fired, + onStartupFired, + `Expected runtime.onStartup to ${onStartupFired ? "" : "not "} fire` + ); +} + +add_task(async function test_should_fire_on_addon_update() { + Preferences.set("extensions.logging.enabled", false); + + await promiseStartupManager(); + + const EXTENSION_ID = + "test_runtime_on_installed_addon_update@tests.mozilla.org"; + + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + update_url: `http://localhost:${port}/test_update.json`, + }, + }, + }, + background, + }); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_runtime_on_installed-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + testServer.registerFile( + "/addons/test_runtime_on_installed-2.0.xpi", + webExtensionFile + ); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + equal(addon.version, "1.0", "The installed addon has the correct version"); + + let update = await promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + + let promiseInstalled = promiseAddonEvent("onInstalled"); + await promiseCompleteAllInstalls([install]); + + await extension.awaitMessage("reloading"); + + let [updated_addon] = await promiseInstalled; + equal( + updated_addon.version, + "2.0", + "The updated addon has the correct version" + ); + + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "update", + onInstalledPrevious: "1.0", + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_should_fire_on_browser_update() { + const EXTENSION_ID = + "test_runtime_on_installed_browser_update@tests.mozilla.org"; + + await promiseStartupManager("1"); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + // Restart the browser. + await promiseRestartManager("1"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser. + await promiseRestartManager("2"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + // Restart the browser. + await promiseRestartManager("2"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: false, + }); + + // Update the browser again. + await promiseRestartManager("3"); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: true, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "browser_update", + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +add_task(async function test_should_not_fire_on_reload() { + const EXTENSION_ID = "test_runtime_on_installed_reload@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + extension.sendMessage("reload-extension"); + extension.setRestarting(); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +add_task(async function test_should_not_fire_on_restart() { + const EXTENSION_ID = "test_runtime_on_installed_restart@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledTemporary: false, + onInstalledReason: "install", + }); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + await addon.disable(); + await addon.enable(); + await extension.awaitStartup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: false, + }); + + await extension.markUnloaded(); + await promiseShutdownManager(); +}); + +add_task(async function test_temporary_installation() { + const EXTENSION_ID = + "test_runtime_on_installed_addon_temporary@tests.mozilla.org"; + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + version: "1.0", + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await expectEvents(extension, { + onStartupFired: false, + onInstalledFired: true, + onInstalledReason: "install", + onInstalledTemporary: true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js new file mode 100644 index 0000000000..7365a13f93 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports.js @@ -0,0 +1,69 @@ +"use strict"; + +add_task(async function test_port_disconnected_from_wrong_window() { + let extensionData = { + background() { + let num = 0; + let ports = {}; + let done = false; + + browser.runtime.onConnect.addListener(port => { + num++; + ports[num] = port; + + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "port-2-response", "Got port 2 response"); + browser.test.sendMessage(msg + "-received"); + done = true; + }); + + port.onDisconnect.addListener(err => { + if (port === ports[1]) { + browser.test.log("Port 1 disconnected, sending message via port 2"); + ports[2].postMessage("port-2-msg"); + } else { + browser.test.assertTrue( + done, + "Port 2 disconnected only after a full roundtrip received" + ); + } + }); + + browser.test.sendMessage("port-connect-" + num); + }); + }, + files: { + "page.html": ` + <!DOCTYPE html><meta charset="utf8"> + <script src="script.js"></script> + `, + "script.js"() { + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq(msg, "port-2-msg", "Got message via port 2"); + port.postMessage("port-2-response"); + }); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + let url = `moz-extension://${extension.uuid}/page.html`; + await extension.startup(); + + let page1 = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("port-connect-1"); + info("First page opened port 1"); + + let page2 = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("port-connect-2"); + info("Second page opened port 2"); + + info("Closing the first page should not close port 2"); + await page1.close(); + await extension.awaitMessage("port-2-response-received"); + info("Roundtrip message through port 2 received"); + + await page2.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js new file mode 100644 index 0000000000..7b0cf01d08 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_ports_gc.js @@ -0,0 +1,168 @@ +"use strict"; + +let gcExperimentAPIs = { + gcHelper: { + schema: "schema.json", + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["gcHelper"]], + }, + }, +}; + +let gcExperimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "gcHelper", + functions: [ + { + name: "forceGarbageCollect", + type: "function", + parameters: [], + async: true, + }, + { + name: "registerWitness", + type: "function", + parameters: [ + { + name: "obj", + // Expected type is "object", but using "any" here to ensure that + // the parameter is untouched (not normalized). + type: "any", + }, + ], + returns: { type: "number" }, + }, + { + name: "isGarbageCollected", + type: "function", + parameters: [ + { + name: "witnessId", + description: "return value of registerWitness", + type: "number", + }, + ], + returns: { type: "boolean" }, + }, + ], + }, + ]), + "child.js": () => { + let { setTimeout } = ChromeUtils.import("resource://gre/modules/Timer.jsm"); + /* globals ExtensionAPI */ + this.gcHelper = class extends ExtensionAPI { + getAPI(context) { + let witnesses = new Map(); + return { + gcHelper: { + async forceGarbageCollect() { + // Logic copied from test_ext_contexts_gc.js + for (let i = 0; i < 3; ++i) { + Cu.forceShrinkingGC(); + Cu.forceCC(); + Cu.forceGC(); + await new Promise(resolve => setTimeout(resolve, 0)); + } + }, + registerWitness(obj) { + let witnessId = witnesses.size; + witnesses.set(witnessId, Cu.getWeakReference(obj)); + return witnessId; + }, + isGarbageCollected(witnessId) { + return witnesses.get(witnessId).get() === null; + }, + }, + }; + } + }; + }, +}; + +// Verify that the experiment is working as intended before using it in tests. +add_task(async function test_gc_experiment() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + experiment_apis: gcExperimentAPIs, + }, + files: gcExperimentFiles, + async background() { + let obj1 = {}; + let obj2 = {}; + let witness1 = browser.gcHelper.registerWitness(obj1); + let witness2 = browser.gcHelper.registerWitness(obj2); + obj1 = null; + await browser.gcHelper.forceGarbageCollect(); + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witness1), + "obj1 should have been garbage-collected" + ); + browser.test.assertFalse( + browser.gcHelper.isGarbageCollected(witness2), + "obj2 should not have been garbage-collected" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_port_gc() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + experiment_apis: gcExperimentAPIs, + }, + files: gcExperimentFiles, + async background() { + let witnessPortSender; + let witnessPortReceiver; + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("daName", port.name, "expected port"); + witnessPortReceiver = browser.gcHelper.registerWitness(port); + port.disconnect(); + }); + + // runtime.connect() only triggers onConnect for different contexts, + // so create a frame to have a different context. + // A blank frame in a moz-extension:-document will have access to the + // extension APIs. + let frameWindow = await new Promise(resolve => { + let f = document.createElement("iframe"); + f.onload = () => resolve(f.contentWindow); + document.body.append(f); + }); + await new Promise(resolve => { + let port = frameWindow.browser.runtime.connect({ name: "daName" }); + witnessPortSender = browser.gcHelper.registerWitness(port); + port.onDisconnect.addListener(() => resolve()); + }); + + await browser.gcHelper.forceGarbageCollect(); + + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witnessPortSender), + "runtime.connect() port should have been garbage-collected" + ); + browser.test.assertTrue( + browser.gcHelper.isGarbageCollected(witnessPortReceiver), + "runtime.onConnect port should have been garbage-collected" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js new file mode 100644 index 0000000000..a7404cf5dd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage.js @@ -0,0 +1,452 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function runtimeSendMessageReply() { + function background() { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { + respond(msg); + }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-promise-false") { + return Promise.resolve(false); + } else if (msg == "respond-false") { + // return false means that respond() is not expected to be called. + setTimeout(() => respond("should be ignored")); + return false; + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg == "throw-error") { + throw new Error(msg); + } else if (msg === "respond-uncloneable") { + return Promise.resolve(window); + } else if (msg === "reject-uncloneable") { + return Promise.reject(window); + } else if (msg == "reject-undefined") { + return Promise.reject(); + } else if (msg == "throw-undefined") { + throw undefined; // eslint-disable-line no-throw-literal + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + // If a response from another listener is received first, this + // exception should be ignored. Test fails if it is not. + + // All this is of course stupid, but some extensions depend on it. + msg.blah.this.throws(); + } + }); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); + } + + function senderScript() { + Promise.all([ + browser.runtime.sendMessage("respond-now"), + browser.runtime.sendMessage("respond-now-2"), + new Promise(resolve => + browser.runtime.sendMessage("respond-soon", resolve) + ), + browser.runtime.sendMessage("respond-promise"), + browser.runtime.sendMessage("respond-promise-false"), + browser.runtime.sendMessage("respond-false"), + browser.runtime.sendMessage("respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { + resolve(response); + }); + }), + + browser.runtime + .sendMessage("respond-error") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("throw-error") + .catch(error => Promise.resolve({ error })), + + browser.runtime + .sendMessage("respond-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("reject-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("reject-undefined") + .catch(error => Promise.resolve({ error })), + browser.runtime + .sendMessage("throw-undefined") + .catch(error => Promise.resolve({ error })), + ]) + .then( + ([ + respondNow, + respondNow2, + respondSoon, + respondPromise, + respondPromiseFalse, + respondFalse, + respondNever, + respondNever2, + respondError, + throwError, + respondUncloneable, + rejectUncloneable, + rejectUndefined, + throwUndefined, + ]) => { + browser.test.assertEq( + "respond-now", + respondNow, + "Got the expected immediate response" + ); + browser.test.assertEq( + "respond-now-2", + respondNow2, + "Got the expected immediate response from the second listener" + ); + browser.test.assertEq( + "respond-soon", + respondSoon, + "Got the expected delayed response" + ); + browser.test.assertEq( + "respond-promise", + respondPromise, + "Got the expected promise response" + ); + browser.test.assertEq( + false, + respondPromiseFalse, + "Got the expected false value as a promise result" + ); + browser.test.assertEq( + undefined, + respondFalse, + "Got the expected no-response when onMessage returns false" + ); + browser.test.assertEq( + undefined, + respondNever, + "Got the expected no-response resolution" + ); + browser.test.assertEq( + undefined, + respondNever2, + "Got the expected no-response resolution" + ); + + browser.test.assertEq( + "respond-error", + respondError.error.message, + "Got the expected error response" + ); + browser.test.assertEq( + "throw-error", + throwError.error.message, + "Got the expected thrown error response" + ); + + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + respondUncloneable.error.message, + "An uncloneable response should be ignored" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUncloneable.error.message, + "Got the expected error for a rejection with an uncloneable value" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUndefined.error.message, + "Got the expected error for a void rejection" + ); + browser.test.assertEq( + "An unexpected error occurred", + throwUndefined.error.message, + "Got the expected error for a void throw" + ); + + browser.test.notifyPass("sendMessage"); + } + ) + .catch(e => { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("sendMessage"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("sendMessage"); + await extension.unload(); +}); + +add_task(async function runtimeSendMessageBlob() { + function background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertTrue(msg.blob instanceof Blob, "Message is a blob"); + return Promise.resolve(msg); + }); + + let childFrame = document.createElement("iframe"); + childFrame.src = "extensionpage.html"; + document.body.appendChild(childFrame); + } + + function senderScript() { + browser.runtime + .sendMessage({ blob: new Blob(["hello"]) }) + .then(response => { + browser.test.assertTrue( + response.blob instanceof Blob, + "Response is a blob" + ); + browser.test.notifyPass("sendBlob"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "senderScript.js": senderScript, + "extensionpage.html": `<!DOCTYPE html><meta charset="utf-8"><script src="senderScript.js"></script>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("sendBlob"); + await extension.unload(); +}); + +add_task(async function sendMessageResponseGC() { + function background() { + let savedResolve, savedRespond; + + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Got request: ${msg}`); + switch (msg) { + case "ping": + respond("pong"); + return; + + case "promise-save": + return new Promise(resolve => { + savedResolve = resolve; + }); + case "promise-resolve": + savedResolve("saved-resolve"); + return; + case "promise-never": + return new Promise(r => {}); + + case "callback-save": + savedRespond = respond; + return true; + case "callback-call": + savedRespond("saved-respond"); + return; + case "callback-never": + return true; + } + }); + + const frame = document.createElement("iframe"); + frame.src = "page.html"; + document.body.appendChild(frame); + } + + function page() { + browser.test.onMessage.addListener(msg => { + browser.runtime.sendMessage(msg).then( + response => { + if (response) { + browser.test.log(`Got response: ${response}`); + browser.test.sendMessage(response); + } + }, + error => { + browser.test.assertEq( + "Promised response from onMessage listener went out of scope", + error.message, + `Promise rejected with the correct error message` + ); + + browser.test.assertTrue( + /^moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js/.test(error.fileName), + `Promise rejected with the correct error filename: ${error.fileName}` + ); + + browser.test.assertEq( + 4, + error.lineNumber, + `Promise rejected with the correct error line number` + ); + + browser.test.assertTrue( + /moz-extension:\/\/[\w-]+\/%7B[\w-]+%7D\.js:4/.test(error.stack), + `Promise rejected with the correct error stack: ${error.stack}` + ); + browser.test.sendMessage("rejected"); + } + ); + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "page.html": + "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>", + "page.js": page, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Setup long-running tasks before GC. + extension.sendMessage("promise-save"); + extension.sendMessage("callback-save"); + + // Test returning a Promise that can never resolve. + extension.sendMessage("promise-never"); + + extension.sendMessage("ping"); + await extension.awaitMessage("pong"); + + Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false); + await extension.awaitMessage("rejected"); + + // Test returning `true` without holding the response handle. + extension.sendMessage("callback-never"); + + extension.sendMessage("ping"); + await extension.awaitMessage("pong"); + + Services.ppmm.loadProcessScript("data:,Components.utils.forceGC()", false); + await extension.awaitMessage("rejected"); + + // Test that promises from long-running tasks didn't get GCd. + extension.sendMessage("promise-resolve"); + await extension.awaitMessage("saved-resolve"); + + extension.sendMessage("callback-call"); + await extension.awaitMessage("saved-respond"); + + ok("Long running tasks responded"); + await extension.unload(); +}); + +add_task(async function sendMessage_async_response_multiple_contexts() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Background got request: ${msg}`); + + switch (msg) { + case "ask-bg-fast": + respond("bg-respond"); + return true; + + case "ask-bg-slow": + return new Promise(r => setTimeout(() => r("bg-promise")), 1000); + } + }); + browser.test.sendMessage("bg-ready"); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["cs.js"], + }, + ], + }, + + files: { + "page.html": + "<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>", + "page.js"() { + browser.runtime.onMessage.addListener((msg, _, respond) => { + browser.test.log(`Page got request: ${msg}`); + + switch (msg) { + case "ask-page-fast": + respond("page-respond"); + return true; + + case "ask-page-slow": + return new Promise(r => setTimeout(() => r("page-promise")), 500); + } + }); + browser.test.sendMessage("page-ready"); + }, + + "cs.js"() { + Promise.all([ + browser.runtime.sendMessage("ask-bg-fast"), + browser.runtime.sendMessage("ask-bg-slow"), + browser.runtime.sendMessage("ask-page-fast"), + browser.runtime.sendMessage("ask-page-slow"), + ]).then(responses => { + browser.test.assertEq( + responses.join(), + ["bg-respond", "bg-promise", "page-respond", "page-promise"].join(), + "Got all expected responses from correct contexts" + ); + browser.test.notifyPass("cs-done"); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-ready"); + + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitMessage("page-ready"); + + let content = await ExtensionTestUtils.loadContentPage( + BASE_URL + "/file_sample.html" + ); + await extension.awaitFinish("cs-done"); + await content.close(); + + await page.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js new file mode 100644 index 0000000000..ecbaba5cfe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_args.js @@ -0,0 +1,101 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function() { + const ID1 = "sendMessage1@tests.mozilla.org"; + const ID2 = "sendMessage2@tests.mozilla.org"; + + let extension1 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener((...args) => { + browser.runtime.sendMessage(...args); + }); + + let frame = document.createElement("iframe"); + frame.src = "page.html"; + document.body.appendChild(frame); + }, + manifest: { applications: { gecko: { id: ID1 } } }, + files: { + "page.js": function() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.sendMessage("received-page", { msg, sender }); + }); + // Let them know we're done loading the page. + browser.test.sendMessage("page-ready"); + }, + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + }, + }); + + let extension2 = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onMessageExternal.addListener((msg, sender) => { + browser.test.sendMessage("received-external", { msg, sender }); + }); + }, + manifest: { applications: { gecko: { id: ID2 } } }, + }); + + await Promise.all([extension1.startup(), extension2.startup()]); + await extension1.awaitMessage("page-ready"); + + // Check that a message was sent within extension1. + async function checkLocalMessage(msg) { + let result = await extension1.awaitMessage("received-page"); + deepEqual(result.msg, msg, "Received internal message"); + equal(result.sender.id, ID1, "Received correct sender id"); + } + + // Check that a message was sent from extension1 to extension2. + async function checkRemoteMessage(msg) { + let result = await extension2.awaitMessage("received-external"); + deepEqual(result.msg, msg, "Received cross-extension message"); + equal(result.sender.id, ID1, "Received correct sender id"); + } + + // sendMessage() takes 3 arguments: + // optional extensionID + // mandatory message + // optional options + // Due to this insane design we parse its arguments manually. This + // test is meant to cover all the combinations. + + // A single null or undefined argument is allowed, and represents the message + extension1.sendMessage(null); + await checkLocalMessage(null); + + // With one argument, it must be just the message + extension1.sendMessage("message"); + await checkLocalMessage("message"); + + // With two arguments, these cases should be treated as (extensionID, message) + extension1.sendMessage(ID2, "message"); + await checkRemoteMessage("message"); + + extension1.sendMessage(ID2, { msg: "message" }); + await checkRemoteMessage({ msg: "message" }); + + // And these should be (message, options) + extension1.sendMessage("message", {}); + await checkLocalMessage("message"); + + // or (message, non-callback), pick your poison + extension1.sendMessage("message", undefined); + await checkLocalMessage("message"); + + // With three arguments, we send a cross-extension message + extension1.sendMessage(ID2, "message", {}); + await checkRemoteMessage("message"); + + // Even when the last one is null or undefined + extension1.sendMessage(ID2, "message", undefined); + await checkRemoteMessage("message"); + + // The four params case is unambigous, so we allow null as a (non-) callback + extension1.sendMessage(ID2, "message", {}, null); + await checkRemoteMessage("message"); + + await Promise.all([extension1.unload(), extension2.unload()]); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js new file mode 100644 index 0000000000..a56c2fdc79 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_errors.js @@ -0,0 +1,66 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sendMessage_error() { + async function background() { + let circ = {}; + circ.circ = circ; + let testCases = [ + // [arguments, expected error string], + [[], "runtime.sendMessage's message argument is missing"], + [ + [null, null, null, 42], + "runtime.sendMessage's last argument is not a function", + ], + [[null, null, 1], "runtime.sendMessage's options argument is invalid"], + [ + [1, null, null], + "runtime.sendMessage's extensionId argument is invalid", + ], + [ + [null, null, null, null, null], + "runtime.sendMessage received too many arguments", + ], + + // Even when the parameters are accepted, we still expect an error + // because there is no onMessage listener. + [ + [null, null, null], + "Could not establish connection. Receiving end does not exist.", + ], + + // Structured cloning doesn't work with DOM objects + [[null, location, null], "The object could not be cloned."], + [[null, [circ, location], null], "The object could not be cloned."], + ]; + + // Repeat all tests with the undefined value instead of null. + for (let [args, expectedError] of testCases.slice()) { + args = args.map(arg => (arg === null ? undefined : arg)); + testCases.push([args, expectedError]); + } + + for (let [args, expectedError] of testCases) { + let description = `runtime.sendMessage(${args.map(String).join(", ")})`; + + await browser.test.assertRejects( + browser.runtime.sendMessage(...args), + expectedError, + `expected error message for ${description}` + ); + } + + browser.test.notifyPass("sendMessage parameter validation"); + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("sendMessage parameter validation"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js new file mode 100644 index 0000000000..9827a329e3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_multiple.js @@ -0,0 +1,67 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Regression test for bug 1655624: When there are multiple onMessage receivers +// that both handle the response asynchronously, destroying the context of one +// recipient should not prevent the other recipient from sending a reply. +add_task(async function onMessage_ignores_destroyed_contexts() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener(async msg => { + if (msg !== "startTest") { + return; + } + try { + let res = await browser.runtime.sendMessage("msg_from_bg"); + browser.test.assertEq(0, res, "Result from onMessage"); + browser.test.notifyPass("handled_onMessage"); + } catch (e) { + browser.test.fail(`Unexpected error: ${e.message} :: ${e.stack}`); + browser.test.notifyFail("handled_onMessage"); + } + }); + }, + files: { + "tab.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="tab.js"></script> + `, + "tab.js": () => { + let where = location.search.slice(1); + let resolveOnMessage; + browser.runtime.onMessage.addListener(async msg => { + browser.test.assertEq("msg_from_bg", msg, `onMessage at ${where}`); + browser.test.sendMessage(`received:${where}`); + return new Promise(resolve => { + resolveOnMessage = resolve; + }); + }); + browser.test.onMessage.addListener(msg => { + if (msg === `resolveOnMessage:${where}`) { + resolveOnMessage(0); + } + }); + }, + }, + }); + await extension.startup(); + let tabToCloseEarly = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html?tabToCloseEarly`, + { extension } + ); + let tabToRespond = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html?tabToRespond`, + { extension } + ); + extension.sendMessage("startTest"); + await Promise.all([ + extension.awaitMessage("received:tabToCloseEarly"), + extension.awaitMessage("received:tabToRespond"), + ]); + await tabToCloseEarly.close(); + extension.sendMessage("resolveOnMessage:tabToRespond"); + await extension.awaitFinish("handled_onMessage"); + await tabToRespond.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js new file mode 100644 index 0000000000..23d8b05f83 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_runtime_sendMessage_no_receiver.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sendMessage_without_listener() { + async function background() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist.", + "Correct error when there are no receivers from background" + ); + + browser.test.sendMessage("sendMessage-error-bg"); + } + let extensionData = { + background, + files: { + "page.html": `<!doctype><meta charset=utf-8><script src="page.js"></script>`, + async "page.js"() { + await browser.test.assertRejects( + browser.runtime.sendMessage("msg"), + "Could not establish connection. Receiving end does not exist.", + "Correct error when there are no receivers from extension page" + ); + + browser.test.notifyPass("sendMessage-error-page"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("sendMessage-error-bg"); + + let url = `moz-extension://${extension.uuid}/page.html`; + let page = await ExtensionTestUtils.loadContentPage(url, { extension }); + await extension.awaitFinish("sendMessage-error-page"); + await page.close(); + + await extension.unload(); +}); + +add_task(async function test_chrome_sendMessage_without_listener() { + function background() { + /* globals chrome */ + browser.test.assertEq( + null, + chrome.runtime.lastError, + "no lastError before call" + ); + let retval = chrome.runtime.sendMessage("msg"); + browser.test.assertEq( + null, + chrome.runtime.lastError, + "no lastError after call" + ); + browser.test.assertEq( + undefined, + retval, + "return value of chrome.runtime.sendMessage without callback" + ); + + let isAsyncCall = false; + retval = chrome.runtime.sendMessage("msg", reply => { + browser.test.assertEq(undefined, reply, "no reply"); + browser.test.assertTrue( + isAsyncCall, + "chrome.runtime.sendMessage's callback must be called asynchronously" + ); + browser.test.assertEq( + undefined, + retval, + "return value of chrome.runtime.sendMessage with callback" + ); + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + chrome.runtime.lastError.message + ); + browser.test.notifyPass("finished chrome.runtime.sendMessage"); + }); + isAsyncCall = true; + } + let extensionData = { + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await extension.awaitFinish("finished chrome.runtime.sendMessage"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js new file mode 100644 index 0000000000..80641d7be4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_cookies.js @@ -0,0 +1,131 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +const WIN = `<html><body>dummy page setting a same-site cookie</body></html>`; + +// Small red image. +const IMG_BYTES = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12" + + "P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==" +); + +server.registerPathHandler("/same_site_cookies", (request, response) => { + // avoid confusing cache behaviors + response.setHeader("Cache-Control", "no-cache", false); + + if (request.queryString === "loadWin") { + response.write(WIN); + return; + } + + // using startsWith and discard the math random + if (request.queryString.startsWith("loadImage")) { + response.setHeader( + "Set-Cookie", + "myKey=mySameSiteExtensionCookie; samesite=strict", + true + ); + response.setHeader("Content-Type", "image/png"); + response.write(IMG_BYTES); + return; + } + + if (request.queryString === "loadXHR") { + let cookie = "noCookie"; + if (request.hasHeader("Cookie")) { + cookie = request.getHeader("Cookie"); + } + response.setHeader("Content-Type", "text/plain"); + response.write(cookie); + return; + } + + // We should never get here, but just in case return something unexpected. + response.write("D'oh"); +}); + +/* Description of the test: + * (1) We load an image from mochi.test which sets a same site cookie + * (2) We have the web extension perform an XHR request to mochi.test + * (3) We verify the web-extension can access the same-site cookie + */ + +add_task(async function test_webRequest_same_site_cookie_access() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + content_scripts: [ + { + matches: ["http://example.com/*"], + run_at: "document_end", + js: ["content_script.js"], + }, + ], + }, + + background() { + browser.test.onMessage.addListener(msg => { + if (msg === "verify-same-site-cookie-moz-extension") { + let xhr = new XMLHttpRequest(); + try { + xhr.open( + "GET", + "http://example.com/same_site_cookies?loadXHR", + true + ); + xhr.onload = function() { + browser.test.assertEq( + "myKey=mySameSiteExtensionCookie", + xhr.responseText, + "cookie should be accessible from moz-extension context" + ); + browser.test.sendMessage("same-site-cookie-test-done"); + }; + xhr.onerror = function() { + browser.test.fail("xhr onerror"); + browser.test.sendMessage("same-site-cookie-test-done"); + }; + } catch (e) { + browser.test.fail("xhr failure: " + e); + } + xhr.send(); + } + }); + }, + + files: { + "content_script.js": function() { + let myImage = document.createElement("img"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + myImage.wrappedJSObject.setAttribute( + "src", + "http://example.com/same_site_cookies?loadImage" + Math.random() + ); + myImage.onload = function() { + browser.test.log("image onload"); + browser.test.sendMessage("image-loaded-and-same-site-cookie-set"); + }; + myImage.onerror = function() { + browser.test.log("image onerror"); + }; + document.body.appendChild(myImage); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/same_site_cookies?loadWin" + ); + + await extension.awaitMessage("image-loaded-and-same-site-cookie-set"); + + extension.sendMessage("verify-same-site-cookie-moz-extension"); + await extension.awaitMessage("same-site-cookie-test-done"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js new file mode 100644 index 0000000000..df77f8b0dd --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_same_site_redirects.js @@ -0,0 +1,233 @@ +"use strict"; + +/** + * This test tests various redirection scenarios, and checks whether sameSite + * cookies are sent. + * + * The file has the following tests: + * - verify_firstparty_web_behavior - base case, confirms normal web behavior. + * - samesite_is_foreign_without_host_permissions + * - wildcard_host_permissions_enable_samesite_cookies + * - explicit_host_permissions_enable_samesite_cookies + * - some_host_permissions_enable_some_samesite_cookies + */ + +// This simulates a common pattern used for sites that require authentication. +// After logging in, there may be multiple redirects, HTTP and scripted. +const SITE_START = "start.example.net"; +// set "start" cookies + 302 redirects to found. +const SITE_FOUND = "found.example.net"; +// set "found" cookies + uses a HTML redirect to redir. +const SITE_REDIR = "redir.example.net"; +// set "redir" cookies + 302 redirects to final. +const SITE_FINAL = "final.example.net"; + +const SITE = "example.net"; + +const URL_START = `http://${SITE_START}/start`; + +const server = createHttpServer({ + hosts: [SITE_START, SITE_FOUND, SITE_REDIR, SITE_FINAL], +}); + +function getCookies(request) { + return request.hasHeader("Cookie") ? request.getHeader("Cookie") : ""; +} + +function sendCookies(response, prefix, suffix = "") { + const cookies = [ + prefix + "-none=1; sameSite=none; domain=" + SITE + suffix, + prefix + "-lax=1; sameSite=lax; domain=" + SITE + suffix, + prefix + "-strict=1; sameSite=strict; domain=" + SITE + suffix, + ]; + for (let cookie of cookies) { + response.setHeader("Set-Cookie", cookie, true); + } +} + +function deleteCookies(response, prefix) { + sendCookies(response, prefix, "; expires=Thu, 01 Jan 1970 00:00:00 GMT"); +} + +var receivedCookies = []; + +server.registerPathHandler("/start", (request, response) => { + Assert.equal(request.host, SITE_START); + Assert.equal(getCookies(request), "", "No cookies at start of test"); + + response.setStatusLine(request.httpVersion, 302, "Found"); + sendCookies(response, "start"); + response.setHeader("Location", `http://${SITE_FOUND}/found`); +}); + +server.registerPathHandler("/found", (request, response) => { + Assert.equal(request.host, SITE_FOUND); + receivedCookies.push(getCookies(request)); + + response.setHeader("Content-Type", "text/html; charset=utf-8", false); + deleteCookies(response, "start"); + sendCookies(response, "found"); + response.write(`<script>location = "http://${SITE_REDIR}/redir";</script>`); +}); + +server.registerPathHandler("/redir", (request, response) => { + Assert.equal(request.host, SITE_REDIR); + receivedCookies.push(getCookies(request)); + + response.setStatusLine(request.httpVersion, 302, "Found"); + deleteCookies(response, "found"); + sendCookies(response, "redir"); + response.setHeader("Location", `http://${SITE_FINAL}/final`); +}); + +server.registerPathHandler("/final", (request, response) => { + Assert.equal(request.host, SITE_FINAL); + receivedCookies.push(getCookies(request)); + + response.setStatusLine(request.httpVersion, 302, "Found"); + deleteCookies(response, "redir"); + // In test some_host_permissions_enable_some_samesite_cookies, the cookies + // from the start haven't been cleared due to the lack of host permissions. + // Do that here instead. + deleteCookies(response, "start"); + response.setHeader("Location", "/final_and_clean"); +}); + +// Should be called before any request is made. +function promiseFinalResponse() { + Assert.deepEqual(receivedCookies, [], "Test starts without observed cookies"); + return new Promise(resolve => { + server.registerPathHandler("/final_and_clean", (request, response) => { + Assert.equal(request.host, SITE_FINAL); + Assert.equal(getCookies(request), "", "Cookies cleaned up"); + resolve(receivedCookies.splice(0)); + }); + }); +} + +// Load the page as a child frame of an extension, for the given permissions. +async function getCookiesForLoadInExtension({ permissions }) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions, + }, + files: { + "embedder.html": `<iframe src="${URL_START}"></iframe>`, + }, + }); + await extension.startup(); + let cookiesPromise = promiseFinalResponse(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/embedder.html`, + { extension } + ); + let cookies = await cookiesPromise; + await contentPage.close(); + await extension.unload(); + return cookies; +} + +add_task(async function setup() { + Services.prefs.setIntPref("network.cookie.cookieBehavior", 0); + Services.prefs.setBoolPref("network.cookie.sameSite.laxByDefault", true); + + // Test server runs on http, so disable Secure requirement of sameSite=none. + Services.prefs.setBoolPref( + "network.cookie.sameSite.noneRequiresSecure", + false + ); +}); + +// First verify that our expectations match with the actual behavior on the web. +add_task(async function verify_firstparty_web_behavior() { + let cookiesPromise = promiseFinalResponse(); + let contentPage = await ExtensionTestUtils.loadContentPage(URL_START); + let cookies = await cookiesPromise; + await contentPage.close(); + Assert.deepEqual( + cookies, + // Same expectations as in host_permissions_enable_samesite_cookies + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a first-party load on the web" + ); +}); + +// Verify that an extension without permission behaves like a third-party page. +add_task(async function samesite_is_foreign_without_host_permissions() { + let cookies = await getCookiesForLoadInExtension({ + permissions: [], + }); + + Assert.deepEqual( + cookies, + ["start-none=1", "found-none=1", "redir-none=1"], + "SameSite cookies excluded without permissions" + ); +}); + +// When an extension has permissions for the site, cookies should be included. +add_task(async function wildcard_host_permissions_enable_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: ["*://*.example.net/*"], // = *.SITE + }); + + Assert.deepEqual( + cookies, + // Same expectations as in verify_firstparty_web_behavior. + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a load in an extension frame" + ); +}); + +// When an extension has permissions for the site, cookies should be included. +add_task(async function explicit_host_permissions_enable_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: [ + "*://start.example.net/*", + "*://found.example.net/*", + "*://redir.example.net/*", + "*://final.example.net/*", + ], + }); + + Assert.deepEqual( + cookies, + // Same expectations as in verify_firstparty_web_behavior. + [ + "start-none=1; start-lax=1; start-strict=1", + "found-none=1; found-lax=1; found-strict=1", + "redir-none=1; redir-lax=1; redir-strict=1", + ], + "Expected cookies from a load in an extension frame" + ); +}); + +// When an extension does not have host permissions for all sites, but only +// some, then same-site cookies are only included in requests with the right +// permissions. +add_task(async function some_host_permissions_enable_some_samesite_cookies() { + let cookies = await getCookiesForLoadInExtension({ + permissions: ["*://start.example.net/*", "*://final.example.net/*"], + }); + + Assert.deepEqual( + cookies, + [ + // Missing permission for "found.example.net": + "start-none=1", + // Missing permission for "redir.example.net": + "found-none=1", + // "final.example.net" can see cookies from "start.example.net": + "start-lax=1; start-strict=1; redir-none=1", + ], + "Expected some cookies from a load in an extension frame" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js new file mode 100644 index 0000000000..0a8a5acdef --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_sandbox_var.js @@ -0,0 +1,42 @@ +"use strict"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +function contentScript() { + window.x = 12; + browser.test.assertEq(window.x, 12, "x is 12"); + browser.test.notifyPass("background test passed"); +} + +let extensionData = { + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_idle", + }, + ], + }, + + files: { + "content_script.js": contentScript, + }, +}; + +add_task(async function test_contentscript() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + + await extension.awaitFinish(); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schema.js b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js new file mode 100644 index 0000000000..90b615d10e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schema.js @@ -0,0 +1,79 @@ +"use strict"; + +AddonTestUtils.init(this); + +add_task(async function testEmptySchema() { + function background() { + browser.test.assertEq( + undefined, + browser.manifest, + "browser.manifest is not defined" + ); + browser.test.assertTrue( + !!browser.storage, + "browser.storage should be defined" + ); + browser.test.assertEq( + undefined, + browser.contextMenus, + "browser.contextMenus should not be defined" + ); + browser.test.notifyPass("schema"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["storage"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("schema"); + await extension.unload(); +}); + +add_task(async function test_warnings_as_errors() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { unrecognized_property_that_should_be_treated_as_a_warning: 1 }, + }); + + // Tests should be run with extensions.webextensions.warnings-as-errors=true + // by default, and prevent extensions with manifest warnings from loading. + await Assert.rejects( + extension.startup(), + /unrecognized_property_that_should_be_treated_as_a_warning/, + "extension with invalid manifest should not load if warnings-as-errors=true" + ); + // When ExtensionTestUtils.failOnSchemaWarnings(false) is called, startup is + // expected to succeed, as shown by the next "testUnknownProperties" test. +}); + +add_task(async function testUnknownProperties() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unknownPermission"], + + unknown_property: {}, + }, + + background() {}, + }); + + let { messages } = await promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /processing permissions\.0: Value "unknownPermission"/ }, + { + message: /processing unknown_property: An unexpected property was found in the WebExtension manifest/, + }, + ], + }); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js new file mode 100644 index 0000000000..8eba1b7e83 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas.js @@ -0,0 +1,2097 @@ +"use strict"; + +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); + +let { LocalAPIImplementation, SchemaAPIInterface } = ExtensionCommon; + +const global = this; + +let json = [ + { + namespace: "testing", + + properties: { + PROP1: { value: 20 }, + prop2: { type: "string" }, + prop3: { + $ref: "submodule", + }, + prop4: { + $ref: "submodule", + unsupported: true, + }, + }, + + types: [ + { + id: "type1", + type: "string", + enum: ["value1", "value2", "value3"], + }, + + { + id: "type2", + type: "object", + properties: { + prop1: { type: "integer" }, + prop2: { type: "array", items: { $ref: "type1" } }, + }, + }, + + { + id: "basetype1", + type: "object", + properties: { + prop1: { type: "string" }, + }, + }, + + { + id: "basetype2", + choices: [{ type: "integer" }], + }, + + { + $extend: "basetype1", + properties: { + prop2: { type: "string" }, + }, + }, + + { + $extend: "basetype2", + choices: [{ type: "string" }], + }, + + { + id: "basetype3", + type: "object", + properties: { + baseprop: { type: "string" }, + }, + }, + + { + id: "derivedtype1", + type: "object", + $import: "basetype3", + properties: { + derivedprop: { type: "string" }, + }, + }, + + { + id: "derivedtype2", + type: "object", + $import: "basetype3", + properties: { + derivedprop: { type: "integer" }, + }, + }, + + { + id: "submodule", + type: "object", + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: { type: "integer" }, + }, + ], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true, default: 99 }, + { name: "arg2", type: "boolean", optional: true }, + ], + }, + + { + name: "bar", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true }, + { name: "arg2", type: "boolean" }, + ], + }, + + { + name: "baz", + type: "function", + parameters: [ + { + name: "arg1", + type: "object", + properties: { + prop1: { type: "string" }, + prop2: { type: "integer", optional: true }, + prop3: { type: "integer", unsupported: true }, + }, + }, + ], + }, + + { + name: "qux", + type: "function", + parameters: [{ name: "arg1", $ref: "type1" }], + }, + + { + name: "quack", + type: "function", + parameters: [{ name: "arg1", $ref: "type2" }], + }, + + { + name: "quora", + type: "function", + parameters: [{ name: "arg1", type: "function" }], + }, + + { + name: "quileute", + type: "function", + parameters: [ + { name: "arg1", type: "integer", optional: true }, + { name: "arg2", type: "integer" }, + ], + }, + + { + name: "queets", + type: "function", + unsupported: true, + parameters: [], + }, + + { + name: "quintuplets", + type: "function", + parameters: [ + { + name: "obj", + type: "object", + properties: [], + additionalProperties: { type: "integer" }, + }, + ], + }, + + { + name: "quasar", + type: "function", + parameters: [ + { + name: "abc", + type: "object", + properties: { + func: { + type: "function", + parameters: [{ name: "x", type: "integer" }], + }, + }, + }, + ], + }, + + { + name: "quosimodo", + type: "function", + parameters: [ + { + name: "xyz", + type: "object", + additionalProperties: { type: "any" }, + }, + ], + }, + + { + name: "patternprop", + type: "function", + parameters: [ + { + name: "obj", + type: "object", + properties: { prop1: { type: "string", pattern: "^\\d+$" } }, + patternProperties: { + "(?i)^prop\\d+$": { type: "string" }, + "^foo\\d+$": { type: "string" }, + }, + }, + ], + }, + + { + name: "pattern", + type: "function", + parameters: [ + { name: "arg", type: "string", pattern: "(?i)^[0-9a-f]+$" }, + ], + }, + + { + name: "format", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + hostname: { type: "string", format: "hostname", optional: true }, + url: { type: "string", format: "url", optional: true }, + relativeUrl: { + type: "string", + format: "relativeUrl", + optional: true, + }, + strictRelativeUrl: { + type: "string", + format: "strictRelativeUrl", + optional: true, + }, + imageDataOrStrictRelativeUrl: { + type: "string", + format: "imageDataOrStrictRelativeUrl", + optional: true, + }, + }, + }, + ], + }, + + { + name: "formatDate", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + date: { type: "string", format: "date", optional: true }, + }, + }, + ], + }, + + { + name: "deep", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { + type: "object", + properties: { + bar: { + type: "array", + items: { + type: "object", + properties: { + baz: { + type: "object", + properties: { + required: { type: "integer" }, + optional: { type: "string", optional: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + ], + }, + + { + name: "errors", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + warn: { + type: "string", + pattern: "^\\d+$", + optional: true, + onError: "warn", + }, + ignore: { + type: "string", + pattern: "^\\d+$", + optional: true, + onError: "ignore", + }, + default: { + type: "string", + pattern: "^\\d+$", + optional: true, + }, + }, + }, + ], + }, + + { + name: "localize", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { type: "string", preprocess: "localize", optional: true }, + bar: { type: "string", optional: true }, + url: { + type: "string", + preprocess: "localize", + format: "url", + optional: true, + }, + }, + }, + ], + }, + + { + name: "extended1", + type: "function", + parameters: [{ name: "val", $ref: "basetype1" }], + }, + + { + name: "extended2", + type: "function", + parameters: [{ name: "val", $ref: "basetype2" }], + }, + + { + name: "callderived1", + type: "function", + parameters: [{ name: "value", $ref: "derivedtype1" }], + }, + + { + name: "callderived2", + type: "function", + parameters: [{ name: "value", $ref: "derivedtype2" }], + }, + ], + + events: [ + { + name: "onFoo", + type: "function", + }, + + { + name: "onBar", + type: "function", + extraParameters: [ + { + name: "filter", + type: "integer", + optional: true, + default: 1, + }, + ], + }, + ], + }, + { + namespace: "foreign", + properties: { + foreignRef: { $ref: "testing.submodule" }, + }, + }, + { + namespace: "inject", + properties: { + PROP1: { value: "should inject" }, + }, + }, + { + namespace: "do-not-inject", + properties: { + PROP1: { value: "should not inject" }, + }, + }, +]; + +let tallied = null; + +function tally(kind, ns, name, args) { + tallied = [kind, ns, name, args]; +} + +function verify(...args) { + Assert.equal(JSON.stringify(tallied), JSON.stringify(args)); + tallied = null; +} + +let talliedErrors = []; + +function checkErrors(errors) { + Assert.equal( + talliedErrors.length, + errors.length, + "Got expected number of errors" + ); + for (let [i, error] of errors.entries()) { + Assert.ok( + i in talliedErrors && String(talliedErrors[i]).includes(error), + `${JSON.stringify(error)} is a substring of error ${JSON.stringify( + talliedErrors[i] + )}` + ); + } + + talliedErrors.length = 0; +} + +let permissions = new Set(); + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + callFunction(args) { + tally("call", this.namespace, this.name, args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(args) { + tally("call", this.namespace, this.name, args); + } + + getProperty() { + tally("get", this.namespace, this.name); + } + + setProperty(value) { + tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + tally("addListener", this.namespace, this.name, [listener, args]); + } + + removeListener(listener) { + tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + tally("hasListener", this.namespace, this.name, [listener]); + } +} + +let wrapper = { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + + cloneScope: global, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`); + }, + }, + + logError(message) { + talliedErrors.push(message); + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + shouldInject(ns, name) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(namespace, name); + }, +}; + +add_task(async function() { + let url = "data:," + JSON.stringify(json); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + tallied = null; + Schemas.inject(root, wrapper); + Assert.equal(tallied, null); + + Assert.equal(root.testing.PROP1, 20, "simple value property"); + Assert.equal(root.testing.type1.VALUE1, "value1", "enum type"); + Assert.equal(root.testing.type1.VALUE2, "value2", "enum type"); + + Assert.equal("inject" in root, true, "namespace 'inject' should be injected"); + Assert.equal( + root["do-not-inject"], + undefined, + "namespace 'do-not-inject' should not be injected" + ); + + root.testing.foo(11, true); + verify("call", "testing", "foo", [11, true]); + + root.testing.foo(true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(null, true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(undefined, true); + verify("call", "testing", "foo", [99, true]); + + root.testing.foo(11); + verify("call", "testing", "foo", [11, null]); + + Assert.throws( + () => root.testing.bar(11), + /Incorrect argument types/, + "should throw without required arg" + ); + + Assert.throws( + () => root.testing.bar(11, true, 10), + /Incorrect argument types/, + "should throw with too many arguments" + ); + + root.testing.bar(true); + verify("call", "testing", "bar", [null, true]); + + root.testing.baz({ prop1: "hello", prop2: 22 }); + verify("call", "testing", "baz", [{ prop1: "hello", prop2: 22 }]); + + root.testing.baz({ prop1: "hello" }); + verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]); + + root.testing.baz({ prop1: "hello", prop2: null }); + verify("call", "testing", "baz", [{ prop1: "hello", prop2: null }]); + + Assert.throws( + () => root.testing.baz({ prop2: 12 }), + /Property "prop1" is required/, + "should throw without required property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: "hi", prop3: 12 }), + /Property "prop3" is unsupported by Firefox/, + "should throw with unsupported property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: "hi", prop4: 12 }), + /Unexpected property "prop4"/, + "should throw with unexpected property" + ); + + Assert.throws( + () => root.testing.baz({ prop1: 12 }), + /Expected string instead of 12/, + "should throw with wrong type" + ); + + root.testing.qux("value2"); + verify("call", "testing", "qux", ["value2"]); + + Assert.throws( + () => root.testing.qux("value4"), + /Invalid enumeration value "value4"/, + "should throw for invalid enum value" + ); + + root.testing.quack({ prop1: 12, prop2: ["value1", "value3"] }); + verify("call", "testing", "quack", [ + { prop1: 12, prop2: ["value1", "value3"] }, + ]); + + Assert.throws( + () => + root.testing.quack({ prop1: 12, prop2: ["value1", "value3", "value4"] }), + /Invalid enumeration value "value4"/, + "should throw for invalid array type" + ); + + function f() {} + root.testing.quora(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quora"]) + ); + Assert.equal(tallied[3][0], f); + tallied = null; + + let g = () => 0; + root.testing.quora(g); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quora"]) + ); + Assert.equal(tallied[3][0], g); + tallied = null; + + root.testing.quileute(10); + verify("call", "testing", "quileute", [null, 10]); + + Assert.throws( + () => root.testing.queets(), + /queets is not a function/, + "should throw for unsupported functions" + ); + + root.testing.quintuplets({ a: 10, b: 20, c: 30 }); + verify("call", "testing", "quintuplets", [{ a: 10, b: 20, c: 30 }]); + + Assert.throws( + () => root.testing.quintuplets({ a: 10, b: 20, c: 30, d: "hi" }), + /Expected integer instead of "hi"/, + "should throw for wrong additionalProperties type" + ); + + root.testing.quasar({ func: f }); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["call", "testing", "quasar"]) + ); + Assert.equal(tallied[3][0].func, f); + tallied = null; + + root.testing.quosimodo({ a: 10, b: 20, c: 30 }); + verify("call", "testing", "quosimodo", [{ a: 10, b: 20, c: 30 }]); + tallied = null; + + Assert.throws( + () => root.testing.quosimodo(10), + /Incorrect argument types/, + "should throw for wrong type" + ); + + root.testing.patternprop({ + prop1: "12", + prop2: "42", + Prop3: "43", + foo1: "x", + }); + verify("call", "testing", "patternprop", [ + { prop1: "12", prop2: "42", Prop3: "43", foo1: "x" }, + ]); + tallied = null; + + root.testing.patternprop({ prop1: "12" }); + verify("call", "testing", "patternprop", [{ prop1: "12" }]); + tallied = null; + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", foo1: null }), + /Expected string instead of null/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "xx", prop2: "yy" }), + /String "xx" must match \/\^\\d\+\$\//, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", prop2: 42 }), + /Expected string instead of 42/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", prop2: null }), + /Expected string instead of null/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", propx: "42" }), + /Unexpected property "propx"/, + "should throw for unexpected property" + ); + + Assert.throws( + () => root.testing.patternprop({ prop1: "12", Foo1: "x" }), + /Unexpected property "Foo1"/, + "should throw for unexpected property" + ); + + root.testing.pattern("DEADbeef"); + verify("call", "testing", "pattern", ["DEADbeef"]); + tallied = null; + + Assert.throws( + () => root.testing.pattern("DEADcow"), + /String "DEADcow" must match \/\^\[0-9a-f\]\+\$\/i/, + "should throw for non-match" + ); + + root.testing.format({ hostname: "foo" }); + verify("call", "testing", "format", [ + { + hostname: "foo", + imageDataOrStrictRelativeUrl: null, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + tallied = null; + + for (let invalid of ["", " ", "http://foo", "foo/bar", "foo.com/", "foo?"]) { + Assert.throws( + () => root.testing.format({ hostname: invalid }), + /Invalid hostname/, + "should throw for invalid hostname" + ); + } + + root.testing.format({ url: "http://foo/bar", relativeUrl: "http://foo/bar" }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: null, + relativeUrl: "http://foo/bar", + strictRelativeUrl: null, + url: "http://foo/bar", + }, + ]); + tallied = null; + + root.testing.format({ + relativeUrl: "foo.html", + strictRelativeUrl: "foo.html", + }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: null, + relativeUrl: `${wrapper.url}foo.html`, + strictRelativeUrl: `${wrapper.url}foo.html`, + url: null, + }, + ]); + tallied = null; + + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/png;base64,A", + }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: "data:image/png;base64,A", + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + tallied = null; + + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A", + }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: "data:image/jpeg;base64,A", + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + tallied = null; + + root.testing.format({ imageDataOrStrictRelativeUrl: "foo.html" }); + verify("call", "testing", "format", [ + { + hostname: null, + imageDataOrStrictRelativeUrl: `${wrapper.url}foo.html`, + relativeUrl: null, + strictRelativeUrl: null, + url: null, + }, + ]); + + tallied = null; + + for (let format of ["url", "relativeUrl"]) { + Assert.throws( + () => root.testing.format({ [format]: "chrome://foo/content/" }), + /Access denied/, + "should throw for access denied" + ); + } + + for (let urlString of ["//foo.html", "http://foo/bar.html"]) { + Assert.throws( + () => root.testing.format({ strictRelativeUrl: urlString }), + /must be a relative URL/, + "should throw for non-relative URL" + ); + } + + Assert.throws( + () => + root.testing.format({ + imageDataOrStrictRelativeUrl: "data:image/svg+xml;utf8,A", + }), + /must be a relative or PNG or JPG data:image URL/, + "should throw for non-relative or non PNG/JPG data URL" + ); + + const dates = [ + "2016-03-04", + "2016-03-04T08:00:00Z", + "2016-03-04T08:00:00.000Z", + "2016-03-04T08:00:00-08:00", + "2016-03-04T08:00:00.000-08:00", + "2016-03-04T08:00:00+08:00", + "2016-03-04T08:00:00.000+08:00", + "2016-03-04T08:00:00+0800", + "2016-03-04T08:00:00-0800", + ]; + dates.forEach(str => { + root.testing.formatDate({ date: str }); + verify("call", "testing", "formatDate", [{ date: str }]); + }); + + // Make sure that a trivial change to a valid date invalidates it. + dates.forEach(str => { + Assert.throws( + () => root.testing.formatDate({ date: "0" + str }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + Assert.throws( + () => root.testing.formatDate({ date: str + "0" }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + }); + + const badDates = [ + "I do not look anything like a date string", + "2016-99-99", + "2016-03-04T25:00:00Z", + ]; + badDates.forEach(str => { + Assert.throws( + () => root.testing.formatDate({ date: str }), + /Invalid date string/, + "should throw for invalid iso date string" + ); + }); + + root.testing.deep({ + foo: { bar: [{ baz: { required: 12, optional: "42" } }] }, + }); + verify("call", "testing", "deep", [ + { foo: { bar: [{ baz: { optional: "42", required: 12 } }] } }, + ]); + tallied = null; + + Assert.throws( + () => root.testing.deep({ foo: { bar: [{ baz: { optional: "42" } }] } }), + /Type error for parameter arg \(Error processing foo\.bar\.0\.baz: Property "required" is required\) for testing\.deep/, + "should throw with the correct object path" + ); + + Assert.throws( + () => + root.testing.deep({ + foo: { bar: [{ baz: { optional: 42, required: 12 } }] }, + }), + /Type error for parameter arg \(Error processing foo\.bar\.0\.baz\.optional: Expected string instead of 42\) for testing\.deep/, + "should throw with the correct object path" + ); + + talliedErrors.length = 0; + + root.testing.errors({ default: "0123", ignore: "0123", warn: "0123" }); + verify("call", "testing", "errors", [ + { default: "0123", ignore: "0123", warn: "0123" }, + ]); + checkErrors([]); + + root.testing.errors({ default: "0123", ignore: "x123", warn: "0123" }); + verify("call", "testing", "errors", [ + { default: "0123", ignore: null, warn: "0123" }, + ]); + checkErrors([]); + + ExtensionTestUtils.failOnSchemaWarnings(false); + root.testing.errors({ default: "0123", ignore: "0123", warn: "x123" }); + ExtensionTestUtils.failOnSchemaWarnings(true); + verify("call", "testing", "errors", [ + { default: "0123", ignore: "0123", warn: null }, + ]); + checkErrors(['String "x123" must match /^\\d+$/']); + + root.testing.onFoo.addListener(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onFoo"]) + ); + Assert.equal(tallied[3][0], f); + Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([])); + tallied = null; + + root.testing.onFoo.removeListener(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["removeListener", "testing", "onFoo"]) + ); + Assert.equal(tallied[3][0], f); + tallied = null; + + root.testing.onFoo.hasListener(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["hasListener", "testing", "onFoo"]) + ); + Assert.equal(tallied[3][0], f); + tallied = null; + + Assert.throws( + () => root.testing.onFoo.addListener(10), + /Invalid listener/, + "addListener with non-function should throw" + ); + + root.testing.onBar.addListener(f, 10); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onBar"]) + ); + Assert.equal(tallied[3][0], f); + Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([10])); + tallied = null; + + root.testing.onBar.addListener(f); + Assert.equal( + JSON.stringify(tallied.slice(0, -1)), + JSON.stringify(["addListener", "testing", "onBar"]) + ); + Assert.equal(tallied[3][0], f); + Assert.equal(JSON.stringify(tallied[3][1]), JSON.stringify([1])); + tallied = null; + + Assert.throws( + () => root.testing.onBar.addListener(f, "hi"), + /Incorrect argument types/, + "addListener with wrong extra parameter should throw" + ); + + let target = { prop1: 12, prop2: ["value1", "value3"] }; + let proxy = new Proxy(target, {}); + Assert.throws( + () => root.testing.quack(proxy), + /Expected a plain JavaScript object, got a Proxy/, + "should throw when passing a Proxy" + ); + + if (Symbol.toStringTag) { + let stringTarget = { prop1: 12, prop2: ["value1", "value3"] }; + stringTarget[Symbol.toStringTag] = () => "[object Object]"; + let stringProxy = new Proxy(stringTarget, {}); + Assert.throws( + () => root.testing.quack(stringProxy), + /Expected a plain JavaScript object, got a Proxy/, + "should throw when passing a Proxy" + ); + } + + root.testing.localize({ + foo: "__MSG_foo__", + bar: "__MSG_foo__", + url: "__MSG_http://example.com/__", + }); + verify("call", "testing", "localize", [ + { bar: "__MSG_foo__", foo: "FOO", url: "http://example.com/" }, + ]); + tallied = null; + + Assert.throws( + () => root.testing.localize({ url: "__MSG_/foo/bar__" }), + /\/FOO\/BAR is not a valid URL\./, + "should throw for invalid URL" + ); + + root.testing.extended1({ prop1: "foo", prop2: "bar" }); + verify("call", "testing", "extended1", [{ prop1: "foo", prop2: "bar" }]); + tallied = null; + + Assert.throws( + () => root.testing.extended1({ prop1: "foo", prop2: 12 }), + /Expected string instead of 12/, + "should throw for wrong property type" + ); + + Assert.throws( + () => root.testing.extended1({ prop1: "foo" }), + /Property "prop2" is required/, + "should throw for missing property" + ); + + Assert.throws( + () => root.testing.extended1({ prop1: "foo", prop2: "bar", prop3: "xxx" }), + /Unexpected property "prop3"/, + "should throw for extra property" + ); + + root.testing.extended2("foo"); + verify("call", "testing", "extended2", ["foo"]); + tallied = null; + + root.testing.extended2(12); + verify("call", "testing", "extended2", [12]); + tallied = null; + + Assert.throws( + () => root.testing.extended2(true), + /Incorrect argument types/, + "should throw for wrong argument type" + ); + + root.testing.prop3.sub_foo(); + verify("call", "testing.prop3", "sub_foo", []); + tallied = null; + + Assert.throws( + () => root.testing.prop4.sub_foo(), + /root.testing.prop4 is undefined/, + "should throw for unsupported submodule" + ); + + root.foreign.foreignRef.sub_foo(); + verify("call", "foreign.foreignRef", "sub_foo", []); + tallied = null; + + root.testing.callderived1({ baseprop: "s1", derivedprop: "s2" }); + verify("call", "testing", "callderived1", [ + { baseprop: "s1", derivedprop: "s2" }, + ]); + tallied = null; + + Assert.throws( + () => root.testing.callderived1({ baseprop: "s1", derivedprop: 42 }), + /Error processing derivedprop: Expected string/, + "Two different objects may $import the same base object" + ); + Assert.throws( + () => root.testing.callderived1({ baseprop: "s1" }), + /Property "derivedprop" is required/, + "Object using $import has its local properites" + ); + Assert.throws( + () => root.testing.callderived1({ derivedprop: "s2" }), + /Property "baseprop" is required/, + "Object using $import has imported properites" + ); + + root.testing.callderived2({ baseprop: "s1", derivedprop: 42 }); + verify("call", "testing", "callderived2", [ + { baseprop: "s1", derivedprop: 42 }, + ]); + tallied = null; + + Assert.throws( + () => root.testing.callderived2({ baseprop: "s1", derivedprop: "s2" }), + /Error processing derivedprop: Expected integer/, + "Two different objects may $import the same base object" + ); + Assert.throws( + () => root.testing.callderived2({ baseprop: "s1" }), + /Property "derivedprop" is required/, + "Object using $import has its local properites" + ); + Assert.throws( + () => root.testing.callderived2({ derivedprop: 42 }), + /Property "baseprop" is required/, + "Object using $import has imported properites" + ); +}); + +let deprecatedJson = [ + { + namespace: "deprecated", + + properties: { + accessor: { + type: "string", + writable: true, + deprecated: "This is not the property you are looking for", + }, + }, + + types: [ + { + id: "Type", + type: "string", + }, + ], + + functions: [ + { + name: "property", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + properties: { + foo: { + type: "string", + }, + }, + additionalProperties: { + type: "any", + deprecated: "Unknown property", + }, + }, + ], + }, + + { + name: "value", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "integer", + }, + { + type: "string", + deprecated: "Please use an integer, not ${value}", + }, + ], + }, + ], + }, + + { + name: "choices", + type: "function", + parameters: [ + { + name: "arg", + deprecated: "You have no choices", + choices: [ + { + type: "integer", + }, + ], + }, + ], + }, + + { + name: "ref", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + $ref: "Type", + deprecated: "Deprecated alias", + }, + ], + }, + ], + }, + + { + name: "method", + type: "function", + deprecated: "Do not call this method", + parameters: [], + }, + ], + + events: [ + { + name: "onDeprecated", + type: "function", + deprecated: "This event does not work", + }, + ], + }, +]; + +add_task(async function testDeprecation() { + // This whole test expects deprecation warnings. + ExtensionTestUtils.failOnSchemaWarnings(false); + + let url = "data:," + JSON.stringify(deprecatedJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + root.deprecated.property({ foo: "bar", xxx: "any", yyy: "property" }); + verify("call", "deprecated", "property", [ + { foo: "bar", xxx: "any", yyy: "property" }, + ]); + checkErrors([ + "Warning processing xxx: Unknown property", + "Warning processing yyy: Unknown property", + ]); + + root.deprecated.value(12); + verify("call", "deprecated", "value", [12]); + checkErrors([]); + + root.deprecated.value("12"); + verify("call", "deprecated", "value", ["12"]); + checkErrors(['Please use an integer, not "12"']); + + root.deprecated.choices(12); + verify("call", "deprecated", "choices", [12]); + checkErrors(["You have no choices"]); + + root.deprecated.ref("12"); + verify("call", "deprecated", "ref", ["12"]); + checkErrors(["Deprecated alias"]); + + root.deprecated.method(); + verify("call", "deprecated", "method", []); + checkErrors(["Do not call this method"]); + + void root.deprecated.accessor; + verify("get", "deprecated", "accessor", null); + checkErrors(["This is not the property you are looking for"]); + + root.deprecated.accessor = "x"; + verify("set", "deprecated", "accessor", "x"); + checkErrors(["This is not the property you are looking for"]); + + root.deprecated.onDeprecated.addListener(() => {}); + checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.removeListener(() => {}); + checkErrors(["This event does not work"]); + + root.deprecated.onDeprecated.hasListener(() => {}); + checkErrors(["This event does not work"]); + + ExtensionTestUtils.failOnSchemaWarnings(true); + + Assert.throws( + () => root.deprecated.onDeprecated.hasListener(() => {}), + /This event does not work/, + "Deprecation warning with extensions.webextensions.warnings-as-errors=true" + ); +}); + +let choicesJson = [ + { + namespace: "choices", + + types: [], + + functions: [ + { + name: "meh", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "string", + enum: ["foo", "bar", "baz"], + }, + { + type: "string", + pattern: "florg.*meh", + }, + { + type: "integer", + minimum: 12, + maximum: 42, + }, + ], + }, + ], + }, + + { + name: "foo", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "object", + properties: { + blurg: { + type: "string", + unsupported: true, + optional: true, + }, + }, + additionalProperties: { + type: "string", + }, + }, + { + type: "string", + }, + { + type: "array", + minItems: 2, + maxItems: 3, + items: { + type: "integer", + }, + }, + ], + }, + ], + }, + + { + name: "bar", + type: "function", + parameters: [ + { + name: "arg", + choices: [ + { + type: "object", + properties: { + baz: { + type: "string", + }, + }, + }, + { + type: "array", + items: { + type: "integer", + }, + }, + ], + }, + ], + }, + ], + }, +]; + +add_task(async function testChoices() { + let url = "data:," + JSON.stringify(choicesJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + Assert.throws( + () => root.choices.meh("frog"), + /Value "frog" must either: be one of \["foo", "bar", "baz"\], match the pattern \/florg\.\*meh\/, or be an integer value/ + ); + + Assert.throws( + () => root.choices.meh(4), + /be a string value, or be at least 12/ + ); + + Assert.throws( + () => root.choices.meh(43), + /be a string value, or be no greater than 42/ + ); + + Assert.throws( + () => root.choices.foo([]), + /be an object value, be a string value, or have at least 2 items/ + ); + + Assert.throws( + () => root.choices.foo([1, 2, 3, 4]), + /be an object value, be a string value, or have at most 3 items/ + ); + + Assert.throws( + () => root.choices.foo({ foo: 12 }), + /.foo must be a string value, be a string value, or be an array value/ + ); + + Assert.throws( + () => root.choices.foo({ blurg: "foo" }), + /not contain an unsupported "blurg" property, be a string value, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({}), + /contain the required "baz" property, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({ baz: "x", quux: "y" }), + /not contain an unexpected "quux" property, or be an array value/ + ); + + Assert.throws( + () => root.choices.bar({ baz: "x", quux: "y", foo: "z" }), + /not contain the unexpected properties \[foo, quux\], or be an array value/ + ); +}); + +let permissionsJson = [ + { + namespace: "noPerms", + + types: [], + + functions: [ + { + name: "noPerms", + type: "function", + parameters: [], + }, + + { + name: "fooPerm", + type: "function", + permissions: ["foo"], + parameters: [], + }, + ], + }, + + { + namespace: "fooPerm", + + permissions: ["foo"], + + types: [], + + functions: [ + { + name: "noPerms", + type: "function", + parameters: [], + }, + + { + name: "fooBarPerm", + type: "function", + permissions: ["foo.bar"], + parameters: [], + }, + ], + }, +]; + +add_task(async function testPermissions() { + let url = "data:," + JSON.stringify(permissionsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + + equal( + root.noPerms.fooPerm, + undefined, + "noPerms.fooPerm should not method exist" + ); + + equal(root.fooPerm, undefined, "fooPerm namespace should not exist"); + + info('Add "foo" permission'); + permissions.add("foo"); + + root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.noPerms.fooPerm, + "function", + "noPerms.fooPerm method should exist" + ); + + equal(typeof root.fooPerm, "object", "fooPerm namespace should exist"); + equal( + typeof root.fooPerm.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + + equal( + root.fooPerm.fooBarPerm, + undefined, + "fooPerm.fooBarPerm method should not exist" + ); + + info('Add "foo.bar" permission'); + permissions.add("foo.bar"); + + root = {}; + Schemas.inject(root, wrapper); + + equal(typeof root.noPerms, "object", "noPerms namespace should exist"); + equal( + typeof root.noPerms.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.noPerms.fooPerm, + "function", + "noPerms.fooPerm method should exist" + ); + + equal(typeof root.fooPerm, "object", "fooPerm namespace should exist"); + equal( + typeof root.fooPerm.noPerms, + "function", + "noPerms.noPerms method should exist" + ); + equal( + typeof root.fooPerm.fooBarPerm, + "function", + "noPerms.fooBarPerm method should exist" + ); +}); + +let nestedNamespaceJson = [ + { + namespace: "nested.namespace", + types: [ + { + id: "CustomType", + type: "object", + events: [ + { + name: "onEvent", + type: "function", + }, + ], + properties: { + url: { + type: "string", + }, + }, + functions: [ + { + name: "functionOnCustomType", + type: "function", + parameters: [ + { + name: "title", + type: "string", + }, + ], + }, + ], + }, + ], + properties: { + instanceOfCustomType: { + $ref: "CustomType", + }, + }, + functions: [ + { + name: "create", + type: "function", + parameters: [ + { + name: "title", + type: "string", + }, + ], + }, + ], + }, +]; + +add_task(async function testNestedNamespace() { + let url = "data:," + JSON.stringify(nestedNamespaceJson); + + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, wrapper); + + talliedErrors.length = 0; + + ok(root.nested, "The root object contains the first namespace level"); + ok( + root.nested.namespace, + "The first level object contains the second namespace level" + ); + + ok( + root.nested.namespace.create, + "Got the expected function in the nested namespace" + ); + equal( + typeof root.nested.namespace.create, + "function", + "The property is a function as expected" + ); + + let { instanceOfCustomType } = root.nested.namespace; + + ok( + instanceOfCustomType, + "Got the expected instance of the CustomType defined in the schema" + ); + ok( + instanceOfCustomType.functionOnCustomType, + "Got the expected method in the CustomType instance" + ); + ok( + instanceOfCustomType.onEvent && + instanceOfCustomType.onEvent.addListener && + typeof instanceOfCustomType.onEvent.addListener == "function", + "Got the expected event defined in the CustomType instance" + ); + + instanceOfCustomType.functionOnCustomType("param_value"); + verify( + "call", + "nested.namespace.instanceOfCustomType", + "functionOnCustomType", + ["param_value"] + ); + + let fakeListener = () => {}; + instanceOfCustomType.onEvent.addListener(fakeListener); + verify("addListener", "nested.namespace.instanceOfCustomType", "onEvent", [ + fakeListener, + [], + ]); + instanceOfCustomType.onEvent.removeListener(fakeListener); + verify("removeListener", "nested.namespace.instanceOfCustomType", "onEvent", [ + fakeListener, + ]); + + // TODO: test support properties in a SubModuleType defined in the schema, + // once implemented, e.g.: + // ok("url" in instanceOfCustomType, + // "Got the expected property defined in the CustomType instance"); +}); + +let $importJson = [ + { + namespace: "from_the", + $import: "future", + }, + { + namespace: "future", + properties: { + PROP1: { value: "original value" }, + PROP2: { value: "second original" }, + }, + types: [ + { + id: "Colour", + type: "string", + enum: ["red", "white", "blue"], + }, + ], + functions: [ + { + name: "dye", + type: "function", + parameters: [{ name: "arg", $ref: "Colour" }], + }, + ], + }, + { + namespace: "embrace", + $import: "future", + properties: { + PROP2: { value: "overridden value" }, + }, + types: [ + { + id: "Colour", + type: "string", + enum: ["blue", "orange"], + }, + ], + }, +]; + +add_task(async function test_$import() { + let url = "data:," + JSON.stringify($importJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + tallied = null; + Schemas.inject(root, wrapper); + equal(tallied, null); + + equal(root.from_the.PROP1, "original value", "imported property"); + equal(root.from_the.PROP2, "second original", "second imported property"); + equal(root.from_the.Colour.RED, "red", "imported enum type"); + equal(typeof root.from_the.dye, "function", "imported function"); + + root.from_the.dye("white"); + verify("call", "from_the", "dye", ["white"]); + + Assert.throws( + () => root.from_the.dye("orange"), + /Invalid enumeration value/, + "original imported argument type Colour doesn't include 'orange'" + ); + + equal(root.embrace.PROP1, "original value", "imported property"); + equal(root.embrace.PROP2, "overridden value", "overridden property"); + equal(root.embrace.Colour.ORANGE, "orange", "overridden enum type"); + equal(typeof root.embrace.dye, "function", "imported function"); + + root.embrace.dye("orange"); + verify("call", "embrace", "dye", ["orange"]); + + Assert.throws( + () => root.embrace.dye("white"), + /Invalid enumeration value/, + "overridden argument type Colour doesn't include 'white'" + ); +}); + +add_task(async function testLocalAPIImplementation() { + let countGet2 = 0; + let countProp3 = 0; + let countProp3SubFoo = 0; + + let testingApiObj = { + get PROP1() { + // PROP1 is a schema-defined constant. + throw new Error("Unexpected get PROP1"); + }, + get prop2() { + ++countGet2; + return "prop2 val"; + }, + get prop3() { + throw new Error("Unexpected get prop3"); + }, + set prop3(v) { + // prop3 is a submodule, defined as a function, so the API should not pass + // through assignment to prop3. + throw new Error("Unexpected set prop3"); + }, + }; + let submoduleApiObj = { + get sub_foo() { + ++countProp3; + return () => { + return ++countProp3SubFoo; + }; + }, + }; + + let localWrapper = { + cloneScope: global, + shouldInject(ns, name) { + return name == "testing" || ns == "testing" || ns == "testing.prop3"; + }, + getImplementation(ns, name) { + Assert.ok(ns == "testing" || ns == "testing.prop3"); + if (ns == "testing.prop3" && name == "sub_foo") { + // It is fine to use `null` here because we don't call async functions. + return new LocalAPIImplementation(submoduleApiObj, name, null); + } + // It is fine to use `null` here because we don't call async functions. + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + Assert.equal(countGet2, 0); + Assert.equal(countProp3, 0); + Assert.equal(countProp3SubFoo, 0); + + Assert.equal(root.testing.PROP1, 20); + + Assert.equal(root.testing.prop2, "prop2 val"); + Assert.equal(countGet2, 1); + + Assert.equal(root.testing.prop2, "prop2 val"); + Assert.equal(countGet2, 2); + + info(JSON.stringify(root.testing)); + Assert.equal(root.testing.prop3.sub_foo(), 1); + Assert.equal(countProp3, 1); + Assert.equal(countProp3SubFoo, 1); + + Assert.equal(root.testing.prop3.sub_foo(), 2); + Assert.equal(countProp3, 2); + Assert.equal(countProp3SubFoo, 2); + + root.testing.prop3.sub_foo = () => { + return "overwritten"; + }; + Assert.equal(root.testing.prop3.sub_foo(), "overwritten"); + + root.testing.prop3 = { + sub_foo() { + return "overwritten again"; + }, + }; + Assert.equal(root.testing.prop3.sub_foo(), "overwritten again"); + Assert.equal(countProp3SubFoo, 2); +}); + +let defaultsJson = [ + { + namespace: "defaultsJson", + + types: [], + + functions: [ + { + name: "defaultFoo", + type: "function", + parameters: [ + { + name: "arg", + type: "object", + optional: true, + properties: { + prop1: { type: "integer", optional: true }, + }, + default: { prop1: 1 }, + }, + ], + returns: { + type: "object", + additionalProperties: true, + }, + }, + ], + }, +]; + +add_task(async function testDefaults() { + let url = "data:," + JSON.stringify(defaultsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let testingApiObj = { + defaultFoo: function(arg) { + if (Object.keys(arg) != "prop1") { + throw new Error( + `Received the expected default object, default: ${JSON.stringify( + arg + )}` + ); + } + arg.newProp = 1; + return arg; + }, + }; + + let localWrapper = { + cloneScope: global, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(testingApiObj, name, null); + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 }); + deepEqual(root.defaultsJson.defaultFoo({ prop1: 2 }), { + prop1: 2, + newProp: 1, + }); + deepEqual(root.defaultsJson.defaultFoo(), { prop1: 1, newProp: 1 }); +}); + +let returnsJson = [ + { + namespace: "returns", + types: [ + { + id: "Widget", + type: "object", + properties: { + size: { type: "integer" }, + colour: { type: "string", optional: true }, + }, + }, + ], + functions: [ + { + name: "complete", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + { + name: "optional", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + { + name: "invalid", + type: "function", + returns: { $ref: "Widget" }, + parameters: [], + }, + ], + }, +]; + +add_task(async function testReturns() { + const url = "data:," + JSON.stringify(returnsJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + const apiObject = { + complete() { + return { size: 3, colour: "orange" }; + }, + optional() { + return { size: 4 }; + }, + invalid() { + return {}; + }, + }; + + const localWrapper = { + cloneScope: global, + shouldInject(ns) { + return true; + }, + getImplementation(ns, name) { + return new LocalAPIImplementation(apiObject, name, null); + }, + }; + + const root = {}; + Schemas.inject(root, localWrapper); + + deepEqual(root.returns.complete(), { size: 3, colour: "orange" }); + deepEqual( + root.returns.optional(), + { size: 4 }, + "Missing optional properties is allowed" + ); + + if (AppConstants.DEBUG) { + Assert.throws( + () => root.returns.invalid(), + /Type error for result value \(Property "size" is required\)/, + "Should throw for invalid result in DEBUG builds" + ); + } else { + deepEqual( + root.returns.invalid(), + {}, + "Doesn't throw for invalid result value in release builds" + ); + } +}); + +let booleanEnumJson = [ + { + namespace: "booleanEnum", + + types: [ + { + id: "enumTrue", + type: "boolean", + enum: [true], + }, + ], + functions: [ + { + name: "paramMustBeTrue", + type: "function", + parameters: [{ name: "arg", $ref: "enumTrue" }], + }, + ], + }, +]; + +add_task(async function testBooleanEnum() { + let url = "data:," + JSON.stringify(booleanEnumJson); + Schemas._rootSchema = null; + await Schemas.load(url); + + let root = {}; + tallied = null; + Schemas.inject(root, wrapper); + Assert.equal(tallied, null); + + ok(root.booleanEnum, "namespace exists"); + root.booleanEnum.paramMustBeTrue(true); + verify("call", "booleanEnum", "paramMustBeTrue", [true]); + Assert.throws( + () => root.booleanEnum.paramMustBeTrue(false), + /Type error for parameter arg \(Invalid value false\) for booleanEnum\.paramMustBeTrue\./, + "should throw because enum of the type restricts parameter to true" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js new file mode 100644 index 0000000000..0c90cda51e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_allowed_contexts.js @@ -0,0 +1,157 @@ +"use strict"; + +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +const global = this; + +let schemaJson = [ + { + namespace: "noAllowedContexts", + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_zero", "test_one"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_one"] }, + }, + }, + { + namespace: "defaultContexts", + defaultContexts: ["test_two"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_three"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_two"] }, + }, + }, + { + namespace: "withAllowedContexts", + allowedContexts: ["test_four"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_five"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_three"] }, + }, + }, + { + namespace: "withAllowedContextsAndDefault", + allowedContexts: ["test_six"], + defaultContexts: ["test_seven"], + properties: { + prop1: { type: "object" }, + prop2: { type: "object", allowedContexts: ["test_eight"] }, + prop3: { type: "number", value: 1 }, + prop4: { type: "number", value: 1, allowedContexts: ["numeric_four"] }, + }, + }, + { + namespace: "with_submodule", + defaultContexts: ["test_nine"], + types: [ + { + id: "subtype", + type: "object", + functions: [ + { + name: "noAllowedContexts", + type: "function", + parameters: [], + }, + { + name: "allowedContexts", + allowedContexts: ["test_ten"], + type: "function", + parameters: [], + }, + ], + }, + ], + properties: { + prop1: { $ref: "subtype" }, + prop2: { $ref: "subtype", allowedContexts: ["test_eleven"] }, + }, + }, +]; + +add_task(async function testRestrictions() { + let url = "data:," + JSON.stringify(schemaJson); + await Schemas.load(url); + let results = {}; + let localWrapper = { + cloneScope: global, + shouldInject(ns, name, allowedContexts) { + name = ns ? ns + "." + name : name; + results[name] = allowedContexts.join(","); + return true; + }, + getImplementation() { + // The actual implementation is not significant for this test. + // Let's take this opportunity to see if schema generation is free of + // exceptions even when somehow getImplementation does not return an + // implementation. + }, + }; + + let root = {}; + Schemas.inject(root, localWrapper); + + function verify(path, expected) { + let obj = root; + for (let thing of path.split(".")) { + try { + obj = obj[thing]; + } catch (e) { + // Blech. + } + } + + let result = results[path]; + equal(result, expected, path); + } + + verify("noAllowedContexts", ""); + verify("noAllowedContexts.prop1", ""); + verify("noAllowedContexts.prop2", "test_zero,test_one"); + verify("noAllowedContexts.prop3", ""); + verify("noAllowedContexts.prop4", "numeric_one"); + + verify("defaultContexts", ""); + verify("defaultContexts.prop1", "test_two"); + verify("defaultContexts.prop2", "test_three"); + verify("defaultContexts.prop3", "test_two"); + verify("defaultContexts.prop4", "numeric_two"); + + verify("withAllowedContexts", "test_four"); + verify("withAllowedContexts.prop1", ""); + verify("withAllowedContexts.prop2", "test_five"); + verify("withAllowedContexts.prop3", ""); + verify("withAllowedContexts.prop4", "numeric_three"); + + verify("withAllowedContextsAndDefault", "test_six"); + verify("withAllowedContextsAndDefault.prop1", "test_seven"); + verify("withAllowedContextsAndDefault.prop2", "test_eight"); + verify("withAllowedContextsAndDefault.prop3", "test_seven"); + verify("withAllowedContextsAndDefault.prop4", "numeric_four"); + + verify("with_submodule", ""); + verify("with_submodule.prop1", "test_nine"); + verify("with_submodule.prop1.noAllowedContexts", "test_nine"); + verify("with_submodule.prop1.allowedContexts", "test_ten"); + verify("with_submodule.prop2", "test_eleven"); + // Note: test_nine inherits allowed contexts from the namespace, not from + // submodule. There is no "defaultContexts" for submodule types to not + // complicate things. + verify("with_submodule.prop1.noAllowedContexts", "test_nine"); + verify("with_submodule.prop1.allowedContexts", "test_ten"); + + // This is a constant, so it does not matter that getImplementation does not + // return an implementation since the API injector should take care of it. + equal(root.noAllowedContexts.prop3, 1); + + Assert.throws( + () => root.noAllowedContexts.prop1, + /undefined/, + "Should throw when the implementation is absent." + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js new file mode 100644 index 0000000000..0ef7b81eaf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_async.js @@ -0,0 +1,352 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +let { BaseContext, LocalAPIImplementation } = ExtensionCommon; + +let schemaJson = [ + { + namespace: "testnamespace", + types: [ + { + id: "Widget", + type: "object", + properties: { + size: { type: "integer" }, + colour: { type: "string", optional: true }, + }, + }, + ], + functions: [ + { + name: "one_required", + type: "function", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + }, + ], + }, + { + name: "one_optional", + type: "function", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + optional: true, + }, + ], + }, + { + name: "async_required", + type: "function", + async: "first", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + }, + ], + }, + { + name: "async_optional", + type: "function", + async: "first", + parameters: [ + { + name: "first", + type: "function", + parameters: [], + optional: true, + }, + ], + }, + { + name: "async_result", + type: "function", + async: "callback", + parameters: [ + { + name: "callback", + type: "function", + parameters: [ + { + name: "widget", + $ref: "Widget", + }, + ], + }, + ], + }, + ], + }, +]; + +const global = this; +class StubContext extends BaseContext { + constructor() { + let fakeExtension = { id: "test@web.extension" }; + super("testEnv", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return this.sandbox; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +let context; + +function generateAPIs(extraWrapper, apiObj) { + context = new StubContext(); + let localWrapper = { + cloneScope: global, + shouldInject() { + return true; + }, + getImplementation(namespace, name) { + return new LocalAPIImplementation(apiObj, name, context); + }, + }; + Object.assign(localWrapper, extraWrapper); + + let root = {}; + Schemas.inject(root, localWrapper); + return root.testnamespace; +} + +add_task(async function testParameterValidation() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + + let testnamespace; + function assertThrows(name, ...args) { + Assert.throws( + () => testnamespace[name](...args), + /Incorrect argument types/, + `Expected testnamespace.${name}(${args.map(String).join(", ")}) to throw.` + ); + } + function assertNoThrows(name, ...args) { + try { + testnamespace[name](...args); + } catch (e) { + info( + `testnamespace.${name}(${args + .map(String) + .join(", ")}) unexpectedly threw.` + ); + throw new Error(e); + } + } + let cb = () => {}; + + for (let isChromeCompat of [true, false]) { + info(`Testing API validation with isChromeCompat=${isChromeCompat}`); + testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + one_required() {}, + one_optional() {}, + async_required() {}, + async_optional() {}, + } + ); + + assertThrows("one_required"); + assertThrows("one_required", null); + assertNoThrows("one_required", cb); + assertThrows("one_required", cb, null); + assertThrows("one_required", cb, cb); + + assertNoThrows("one_optional"); + assertNoThrows("one_optional", null); + assertNoThrows("one_optional", cb); + assertThrows("one_optional", cb, null); + assertThrows("one_optional", cb, cb); + + // Schema-based validation happens before an async method is called, so + // errors should be thrown synchronously. + + // The parameter was declared as required, but there was also an "async" + // attribute with the same value as the parameter name, so the callback + // parameter is actually optional. + assertNoThrows("async_required"); + assertNoThrows("async_required", null); + assertNoThrows("async_required", cb); + assertThrows("async_required", cb, null); + assertThrows("async_required", cb, cb); + + assertNoThrows("async_optional"); + assertNoThrows("async_optional", null); + assertNoThrows("async_optional", cb); + assertThrows("async_optional", cb, null); + assertThrows("async_optional", cb, cb); + } +}); + +add_task(async function testCheckAsyncResults() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + + const complete = generateAPIs( + {}, + { + async_result: async () => ({ size: 5, colour: "green" }), + } + ); + + const optional = generateAPIs( + {}, + { + async_result: async () => ({ size: 6 }), + } + ); + + const invalid = generateAPIs( + {}, + { + async_result: async () => ({}), + } + ); + + deepEqual(await complete.async_result(), { size: 5, colour: "green" }); + + deepEqual( + await optional.async_result(), + { size: 6 }, + "Missing optional properties is allowed" + ); + + if (AppConstants.DEBUG) { + await Assert.rejects( + invalid.async_result(), + /Type error for widget value \(Property "size" is required\)/, + "Should throw for invalid callback argument in DEBUG builds" + ); + } else { + deepEqual( + await invalid.async_result(), + {}, + "Invalid callback argument doesn't throw in release builds" + ); + } +}); + +add_task(async function testAsyncResults() { + await Schemas.load("data:," + JSON.stringify(schemaJson)); + function runWithCallback(func) { + info(`Calling testnamespace.${func.name}, expecting callback with result`); + return new Promise(resolve => { + let result = "uninitialized value"; + let returnValue = func(reply => { + result = reply; + resolve(result); + }); + // When a callback is given, the return value must be missing. + Assert.equal(returnValue, undefined); + // Callback must be called asynchronously. + Assert.equal(result, "uninitialized value"); + }); + } + + function runFailCallback(func) { + info(`Calling testnamespace.${func.name}, expecting callback with error`); + return new Promise(resolve => { + func(reply => { + Assert.equal(reply, undefined); + resolve(context.lastError.message); // eslint-disable-line no-undef + }); + }); + } + + for (let isChromeCompat of [true, false]) { + info(`Testing API invocation with isChromeCompat=${isChromeCompat}`); + let testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + async_required(cb) { + Assert.equal(cb, undefined); + return Promise.resolve(1); + }, + async_optional(cb) { + Assert.equal(cb, undefined); + return Promise.resolve(2); + }, + } + ); + if (!isChromeCompat) { + // No promises for chrome. + info("testnamespace.async_required should be a Promise"); + let promise = testnamespace.async_required(); + Assert.ok(promise instanceof context.cloneScope.Promise); + Assert.equal(await promise, 1); + + info("testnamespace.async_optional should be a Promise"); + promise = testnamespace.async_optional(); + Assert.ok(promise instanceof context.cloneScope.Promise); + Assert.equal(await promise, 2); + } + + Assert.equal(await runWithCallback(testnamespace.async_required), 1); + Assert.equal(await runWithCallback(testnamespace.async_optional), 2); + + let otherSandbox = Cu.Sandbox(null, {}); + let errorFactories = [ + msg => { + throw new context.cloneScope.Error(msg); + }, + msg => context.cloneScope.Promise.reject({ message: msg }), + msg => Cu.evalInSandbox(`throw new Error("${msg}")`, otherSandbox), + msg => + Cu.evalInSandbox(`Promise.reject({message: "${msg}"})`, otherSandbox), + ]; + for (let makeError of errorFactories) { + info(`Testing callback/promise with error caused by: ${makeError}`); + testnamespace = generateAPIs( + { + isChromeCompat, + }, + { + async_required() { + return makeError("ONE"); + }, + async_optional() { + return makeError("TWO"); + }, + } + ); + + if (!isChromeCompat) { + // No promises for chrome. + await Assert.rejects( + testnamespace.async_required(), + /ONE/, + "should reject testnamespace.async_required()" + ); + await Assert.rejects( + testnamespace.async_optional(), + /TWO/, + "should reject testnamespace.async_optional()" + ); + } + + Assert.equal(await runFailCallback(testnamespace.async_required), "ONE"); + Assert.equal(await runFailCallback(testnamespace.async_optional), "TWO"); + } + } +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js new file mode 100644 index 0000000000..66de5c8aba --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_interactive.js @@ -0,0 +1,174 @@ +"use strict"; + +const { ExtensionManager } = ChromeUtils.import( + "resource://gre/modules/ExtensionChild.jsm", + null +); + +let experimentAPIs = { + userinputtest: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["userinputtest"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["userinputtest", "child"]], + }, + }, +}; + +let experimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "userinputtest", + functions: [ + { + name: "test", + type: "function", + async: true, + requireUserInput: true, + parameters: [], + }, + { + name: "child", + type: "function", + async: true, + requireUserInput: true, + parameters: [], + }, + ], + }, + ]), + + /* globals ExtensionAPI */ + "parent.js": () => { + this.userinputtest = class extends ExtensionAPI { + getAPI(context) { + return { + userinputtest: { + test() {}, + }, + }; + } + }; + }, + + "child.js": () => { + this.userinputtest = class extends ExtensionAPI { + getAPI(context) { + return { + userinputtest: { + child() {}, + }, + }; + } + }; + }, +}; + +// Set the "handlingUserInput" flag for the given extension's background page. +// Returns an RAIIHelper that should be destruct()ed eventually. +function setHandlingUserInput(extension) { + let extensionChild = ExtensionManager.extensions.get(extension.extension.id); + let bgwin = null; + for (let view of extensionChild.views) { + if (view.viewType == "background") { + bgwin = view.contentWindow; + break; + } + } + notEqual(bgwin, null, "Found background window for the test extension"); + let winutils = bgwin.windowUtils; + return winutils.setHandlingUserInput(true); +} + +// Test that the schema requireUserInput flag works correctly for +// proxied api implementations. +add_task(async function test_proxy() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + background() { + browser.test.onMessage.addListener(async () => { + try { + await browser.userinputtest.test(); + browser.test.sendMessage("result", null); + } catch (err) { + browser.test.sendMessage("result", err.message); + } + }); + }, + manifest: { + permissions: ["experiments.userinputtest"], + experiment_apis: experimentAPIs, + }, + files: experimentFiles, + }); + + await extension.startup(); + + extension.sendMessage("test"); + let result = await extension.awaitMessage("result"); + ok( + /test may only be called from a user input handler/.test(result), + `function failed when not called from a user input handler: ${result}` + ); + + let handle = setHandlingUserInput(extension); + extension.sendMessage("test"); + result = await extension.awaitMessage("result"); + equal( + result, + null, + "function succeeded when called from a user input handler" + ); + handle.destruct(); + + await extension.unload(); +}); + +// Test that the schema requireUserInput flag works correctly for +// non-proxied api implementations. +add_task(async function test_local() { + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + background() { + browser.test.onMessage.addListener(async () => { + try { + await browser.userinputtest.child(); + browser.test.sendMessage("result", null); + } catch (err) { + browser.test.sendMessage("result", err.message); + } + }); + }, + manifest: { + experiment_apis: experimentAPIs, + }, + files: experimentFiles, + }); + + await extension.startup(); + + extension.sendMessage("test"); + let result = await extension.awaitMessage("result"); + ok( + /child may only be called from a user input handler/.test(result), + `function failed when not called from a user input handler: ${result}` + ); + + let handle = setHandlingUserInput(extension); + extension.sendMessage("test"); + result = await extension.awaitMessage("result"); + equal( + result, + null, + "function succeeded when called from a user input handler" + ); + handle.destruct(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js new file mode 100644 index 0000000000..86ce07a5da --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_manifest_permissions.js @@ -0,0 +1,174 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { ExtensionAPI } = ExtensionCommon; + +add_task(async function() { + const schema = [ + { + namespace: "manifest", + types: [ + { + $extend: "WebExtensionManifest", + properties: { + a_manifest_property: { + type: "object", + optional: true, + properties: { + nested: { + optional: true, + type: "any", + }, + }, + additionalProperties: { $ref: "UnrecognizedProperty" }, + }, + }, + }, + ], + }, + { + namespace: "testManifestPermission", + permissions: ["manifest:a_manifest_property"], + functions: [ + { + name: "testMethod", + type: "function", + async: true, + parameters: [], + permissions: ["manifest:a_manifest_property.nested"], + }, + ], + }, + ]; + + class FakeAPI extends ExtensionAPI { + getAPI(context) { + return { + testManifestPermission: { + get testProperty() { + return "value"; + }, + testMethod() { + return Promise.resolve("value"); + }, + }, + }; + } + } + + const modules = { + testNamespace: { + url: URL.createObjectURL(new Blob([FakeAPI.toString()])), + schema: `data:,${JSON.stringify(schema)}`, + scopes: ["addon_parent", "addon_child"], + paths: [["testManifestPermission"]], + }, + }; + + Services.catMan.addCategoryEntry( + "webextension-modules", + "test-manifest-permission", + `data:,${JSON.stringify(modules)}`, + false, + false + ); + + async function testExtension(extensionDef, assertFn) { + let extension = ExtensionTestUtils.loadExtension(extensionDef); + + await extension.startup(); + await assertFn(extension); + await extension.unload(); + } + + await testExtension( + { + manifest: { + a_manifest_property: {}, + }, + background() { + // Test hasPermission method implemented in ExtensionChild.jsm. + browser.test.assertTrue( + "testManifestPermission" in browser, + "The API namespace is defined as expected" + ); + browser.test.assertEq( + undefined, + browser.testManifestPermission && + browser.testManifestPermission.testMethod, + "The property with nested manifest property permission should not be available " + ); + browser.test.notifyPass("test-extension-manifest-without-nested-prop"); + }, + }, + async extension => { + await extension.awaitFinish( + "test-extension-manifest-without-nested-prop" + ); + + // Test hasPermission method implemented in Extension.jsm. + equal( + extension.extension.hasPermission("manifest:a_manifest_property"), + true, + "Got the expected Extension's hasPermission result on existing property" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.nested" + ), + false, + "Got the expected Extension's hasPermission result on existing subproperty" + ); + } + ); + + await testExtension( + { + manifest: { + a_manifest_property: { + nested: {}, + }, + }, + background() { + // Test hasPermission method implemented in ExtensionChild.jsm. + browser.test.assertTrue( + "testManifestPermission" in browser, + "The API namespace is defined as expected" + ); + browser.test.assertEq( + "function", + browser.testManifestPermission && + typeof browser.testManifestPermission.testMethod, + "The property with nested manifest property permission should be available " + ); + browser.test.notifyPass("test-extension-manifest-with-nested-prop"); + }, + }, + async extension => { + await extension.awaitFinish("test-extension-manifest-with-nested-prop"); + + // Test hasPermission method implemented in Extension.jsm. + equal( + extension.extension.hasPermission("manifest:a_manifest_property"), + true, + "Got the expected Extension's hasPermission result on existing property" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.nested" + ), + true, + "Got the expected Extension's hasPermission result on existing subproperty" + ); + equal( + extension.extension.hasPermission( + "manifest:a_manifest_property.unexisting" + ), + false, + "Got the expected Extension's hasPermission result on non existing subproperty" + ); + } + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js new file mode 100644 index 0000000000..ece69a4106 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_privileged.js @@ -0,0 +1,103 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { ExtensionAPI } = ExtensionCommon; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +add_task(async function() { + const schema = [ + { + namespace: "privileged", + permissions: ["mozillaAddons"], + properties: { + test: { + type: "any", + }, + }, + }, + ]; + + class API extends ExtensionAPI { + getAPI(context) { + return { + privileged: { + test: "hello", + }, + }; + } + } + + const modules = { + privileged: { + url: URL.createObjectURL(new Blob([API.toString()])), + schema: `data:,${JSON.stringify(schema)}`, + scopes: ["addon_parent"], + paths: [["privileged"]], + }, + }; + + Services.catMan.addCategoryEntry( + "webextension-modules", + "test-privileged", + `data:,${JSON.stringify(modules)}`, + false, + false + ); + + AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" + ); + await AddonTestUtils.promiseStartupManager(); + + // Try accessing the privileged namespace. + async function testOnce() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "privilegedapi@tests.mozilla.org" } }, + permissions: ["mozillaAddons"], + }, + background() { + browser.test.sendMessage( + "result", + browser.privileged instanceof Object + ); + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + let result = await extension.awaitMessage("result"); + await extension.unload(); + return result; + } + + AddonTestUtils.usePrivilegedSignatures = false; + let result = await testOnce(); + equal( + result, + false, + "Privileged namespace should not be accessible to a regular webextension" + ); + + AddonTestUtils.usePrivilegedSignatures = true; + result = await testOnce(); + equal( + result, + true, + "Privileged namespace should be accessible to a webextension signed with Mozilla Extensions" + ); + + await AddonTestUtils.promiseShutdownManager(); + Services.catMan.deleteCategoryEntry( + "webextension-modules", + "test-privileged", + false + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js new file mode 100644 index 0000000000..d215338dc9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_revoke.js @@ -0,0 +1,507 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +let { SchemaAPIInterface } = ExtensionCommon; + +const global = this; + +let json = [ + { + namespace: "revokableNs", + + permissions: ["revokableNs"], + + properties: { + stringProp: { + type: "string", + writable: true, + }, + + revokableStringProp: { + type: "string", + permissions: ["revokableProp"], + writable: true, + }, + + submoduleProp: { + $ref: "submodule", + }, + + revokableSubmoduleProp: { + $ref: "submodule", + permissions: ["revokableProp"], + }, + }, + + types: [ + { + id: "submodule", + type: "object", + functions: [ + { + name: "sub_foo", + type: "function", + parameters: [], + returns: { type: "integer" }, + }, + ], + }, + ], + + functions: [ + { + name: "func", + type: "function", + parameters: [], + }, + + { + name: "revokableFunc", + type: "function", + parameters: [], + permissions: ["revokableFunc"], + }, + ], + + events: [ + { + name: "onEvent", + type: "function", + }, + + { + name: "onRevokableEvent", + type: "function", + permissions: ["revokableEvent"], + }, + ], + }, +]; + +let recorded = []; + +function record(...args) { + recorded.push(args); +} + +function verify(expected) { + for (let [i, rec] of expected.entries()) { + Assert.deepEqual(recorded[i], rec, `Record ${i} matches`); + } + + equal(recorded.length, expected.length, "Got expected number of records"); + + recorded.length = 0; +} + +registerCleanupFunction(() => { + equal(recorded.length, 0, "No unchecked recorded events at shutdown"); +}); + +let permissions = new Set(); + +class APIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + record(method, args) { + record(method, this.namespace, this.name, args); + } + + revoke(...args) { + this.record("revoke", args); + } + + callFunction(...args) { + this.record("callFunction", args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(...args) { + this.record("callFunctionNoReturn", args); + } + + getProperty(...args) { + this.record("getProperty", args); + } + + setProperty(...args) { + this.record("setProperty", args); + } + + addListener(...args) { + this.record("addListener", args); + } + + removeListener(...args) { + this.record("removeListener", args); + } + + hasListener(...args) { + this.record("hasListener", args); + } +} + +let context = { + cloneScope: global, + + permissionsChanged: null, + + setPermissionsChangedCallback(callback) { + this.permissionsChanged = callback; + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + isPermissionRevokable(permission) { + return permission.startsWith("revokable"); + }, + + getImplementation(namespace, name) { + return new APIImplementation(namespace, name); + }, + + shouldInject() { + return true; + }, +}; + +function ignoreError(fn) { + try { + fn(); + } catch (e) { + // Meh. + } +} + +add_task(async function() { + let url = "data:," + JSON.stringify(json); + await Schemas.load(url); + + let root = {}; + Schemas.inject(root, context); + equal(recorded.length, 0, "No recorded events"); + + let listener = () => {}; + let captured = {}; + + function checkRecorded() { + let possible = [ + ["revokableNs", ["getProperty", "revokableNs", "stringProp", []]], + [ + "revokableProp", + ["getProperty", "revokableNs", "revokableStringProp", []], + ], + + [ + "revokableNs", + ["setProperty", "revokableNs", "stringProp", ["stringProp"]], + ], + [ + "revokableProp", + [ + "setProperty", + "revokableNs", + "revokableStringProp", + ["revokableStringProp"], + ], + ], + + ["revokableNs", ["callFunctionNoReturn", "revokableNs", "func", [[]]]], + [ + "revokableFunc", + ["callFunctionNoReturn", "revokableNs", "revokableFunc", [[]]], + ], + + [ + "revokableNs", + ["callFunction", "revokableNs.submoduleProp", "sub_foo", [[]]], + ], + [ + "revokableProp", + ["callFunction", "revokableNs.revokableSubmoduleProp", "sub_foo", [[]]], + ], + + [ + "revokableNs", + ["addListener", "revokableNs", "onEvent", [listener, []]], + ], + ["revokableNs", ["removeListener", "revokableNs", "onEvent", [listener]]], + ["revokableNs", ["hasListener", "revokableNs", "onEvent", [listener]]], + + [ + "revokableEvent", + ["addListener", "revokableNs", "onRevokableEvent", [listener, []]], + ], + [ + "revokableEvent", + ["removeListener", "revokableNs", "onRevokableEvent", [listener]], + ], + [ + "revokableEvent", + ["hasListener", "revokableNs", "onRevokableEvent", [listener]], + ], + ]; + + let expected = []; + if (permissions.has("revokableNs")) { + for (let [perm, recording] of possible) { + if (!perm || permissions.has(perm)) { + expected.push(recording); + } + } + } + + verify(expected); + } + + function check() { + info(`Check normal access (permissions: [${Array.from(permissions)}])`); + + let ns = root.revokableNs; + + void ns.stringProp; + void ns.revokableStringProp; + + ns.stringProp = "stringProp"; + ns.revokableStringProp = "revokableStringProp"; + + ns.func(); + + if (ns.revokableFunc) { + ns.revokableFunc(); + } + + ns.submoduleProp.sub_foo(); + if (ns.revokableSubmoduleProp) { + ns.revokableSubmoduleProp.sub_foo(); + } + + ns.onEvent.addListener(listener); + ns.onEvent.removeListener(listener); + ns.onEvent.hasListener(listener); + + if (ns.onRevokableEvent) { + ns.onRevokableEvent.addListener(listener); + ns.onRevokableEvent.removeListener(listener); + ns.onRevokableEvent.hasListener(listener); + } + + checkRecorded(); + } + + function capture() { + info("Capture values"); + + let ns = root.revokableNs; + + captured = { ns }; + captured.revokableStringProp = Object.getOwnPropertyDescriptor( + ns, + "revokableStringProp" + ); + + captured.revokableSubmoduleProp = ns.revokableSubmoduleProp; + if (ns.revokableSubmoduleProp) { + captured.sub_foo = ns.revokableSubmoduleProp.sub_foo; + } + + captured.revokableFunc = ns.revokableFunc; + + captured.onRevokableEvent = ns.onRevokableEvent; + if (ns.onRevokableEvent) { + captured.addListener = ns.onRevokableEvent.addListener; + captured.removeListener = ns.onRevokableEvent.removeListener; + captured.hasListener = ns.onRevokableEvent.hasListener; + } + } + + function checkCaptured() { + info( + `Check captured value access (permissions: [${Array.from(permissions)}])` + ); + + let { ns } = captured; + + void ns.stringProp; + ignoreError(() => captured.revokableStringProp.get()); + if (!permissions.has("revokableProp")) { + void ns.revokableStringProp; + } + + ns.stringProp = "stringProp"; + ignoreError(() => captured.revokableStringProp.set("revokableStringProp")); + if (!permissions.has("revokableProp")) { + ns.revokableStringProp = "revokableStringProp"; + } + + ignoreError(() => ns.func()); + ignoreError(() => captured.revokableFunc()); + if (!permissions.has("revokableFunc")) { + ignoreError(() => ns.revokableFunc()); + } + + ignoreError(() => ns.submoduleProp.sub_foo()); + + ignoreError(() => captured.sub_foo()); + if (!permissions.has("revokableProp")) { + ignoreError(() => captured.revokableSubmoduleProp.sub_foo()); + ignoreError(() => ns.revokableSubmoduleProp.sub_foo()); + } + + ignoreError(() => ns.onEvent.addListener(listener)); + ignoreError(() => ns.onEvent.removeListener(listener)); + ignoreError(() => ns.onEvent.hasListener(listener)); + + ignoreError(() => captured.addListener(listener)); + ignoreError(() => captured.removeListener(listener)); + ignoreError(() => captured.hasListener(listener)); + if (!permissions.has("revokableEvent")) { + ignoreError(() => captured.onRevokableEvent.addListener(listener)); + ignoreError(() => captured.onRevokableEvent.removeListener(listener)); + ignoreError(() => captured.onRevokableEvent.hasListener(listener)); + + ignoreError(() => ns.onRevokableEvent.addListener(listener)); + ignoreError(() => ns.onRevokableEvent.removeListener(listener)); + ignoreError(() => ns.onRevokableEvent.hasListener(listener)); + } + + checkRecorded(); + } + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableProp"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ]); + + check(); + checkCaptured(); + + permissions.delete("revokableFunc"); + context.permissionsChanged(); + verify([["revoke", "revokableNs", "revokableFunc", []]]); + + check(); + checkCaptured(); + + permissions.delete("revokableEvent"); + context.permissionsChanged(); + + verify([["revoke", "revokableNs", "onRevokableEvent", []]]); + + check(); + checkCaptured(); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "stringProp", []], + ["revoke", "revokableNs", "func", []], + ["revoke", "revokableNs.submoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onEvent", []], + ]); + + checkCaptured(); + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + context.permissionsChanged(); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableProp"); + permissions.delete("revokableFunc"); + permissions.delete("revokableEvent"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs", "revokableFunc", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + check(); + checkCaptured(); + + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + context.permissionsChanged(); + + check(); + capture(); + checkCaptured(); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([ + ["revoke", "revokableNs", "stringProp", []], + ["revoke", "revokableNs", "revokableStringProp", []], + ["revoke", "revokableNs", "func", []], + ["revoke", "revokableNs", "revokableFunc", []], + ["revoke", "revokableNs.submoduleProp", "sub_foo", []], + ["revoke", "revokableNs.revokableSubmoduleProp", "sub_foo", []], + ["revoke", "revokableNs", "onEvent", []], + ["revoke", "revokableNs", "onRevokableEvent", []], + ]); + + equal(root.revokableNs, undefined, "Namespace is not defined"); + checkCaptured(); +}); + +add_task(async function test_neuter() { + context.permissionsChanged = null; + + let root = {}; + Schemas.inject(root, context); + equal(recorded.length, 0, "No recorded events"); + + permissions.add("revokableNs"); + permissions.add("revokableProp"); + permissions.add("revokableFunc"); + permissions.add("revokableEvent"); + + let ns = root.revokableNs; + let { submoduleProp } = ns; + + let lazyGetter = Object.getOwnPropertyDescriptor(submoduleProp, "sub_foo"); + + permissions.delete("revokableNs"); + context.permissionsChanged(); + verify([]); + + equal(root.revokableNs, undefined, "Should have no revokableNs"); + equal(ns.submoduleProp, undefined, "Should have no ns.submoduleProp"); + + equal(submoduleProp.sub_foo, undefined, "No sub_foo"); + lazyGetter.get.call(submoduleProp); + equal(submoduleProp.sub_foo, undefined, "No sub_foo"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js new file mode 100644 index 0000000000..ebc881a804 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_schemas_roots.js @@ -0,0 +1,242 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { SchemaRoot } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); + +let { SchemaAPIInterface } = ExtensionCommon; + +const global = this; + +let baseSchemaJSON = [ + { + namespace: "base", + + properties: { + PROP1: { value: 42 }, + }, + + types: [ + { + id: "type1", + type: "string", + enum: ["value1", "value2", "value3"], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [{ name: "arg1", $ref: "type1" }], + }, + ], + }, +]; + +let experimentFooJSON = [ + { + namespace: "experiments.foo", + types: [ + { + id: "typeFoo", + type: "string", + enum: ["foo1", "foo2", "foo3"], + }, + ], + + functions: [ + { + name: "foo", + type: "function", + parameters: [ + { name: "arg1", $ref: "typeFoo" }, + { name: "arg2", $ref: "base.type1" }, + ], + }, + ], + }, +]; + +let experimentBarJSON = [ + { + namespace: "experiments.bar", + types: [ + { + id: "typeBar", + type: "string", + enum: ["bar1", "bar2", "bar3"], + }, + ], + + functions: [ + { + name: "bar", + type: "function", + parameters: [ + { name: "arg1", $ref: "typeBar" }, + { name: "arg2", $ref: "base.type1" }, + ], + }, + ], + }, +]; + +let tallied = null; + +function tally(kind, ns, name, args) { + tallied = [kind, ns, name, args]; +} + +function verify(...args) { + equal(JSON.stringify(tallied), JSON.stringify(args)); + tallied = null; +} + +let talliedErrors = []; + +let permissions = new Set(); + +class TallyingAPIImplementation extends SchemaAPIInterface { + constructor(namespace, name) { + super(); + this.namespace = namespace; + this.name = name; + } + + callFunction(args) { + tally("call", this.namespace, this.name, args); + if (this.name === "sub_foo") { + return 13; + } + } + + callFunctionNoReturn(args) { + tally("call", this.namespace, this.name, args); + } + + getProperty() { + tally("get", this.namespace, this.name); + } + + setProperty(value) { + tally("set", this.namespace, this.name, value); + } + + addListener(listener, args) { + tally("addListener", this.namespace, this.name, [listener, args]); + } + + removeListener(listener) { + tally("removeListener", this.namespace, this.name, [listener]); + } + + hasListener(listener) { + tally("hasListener", this.namespace, this.name, [listener]); + } +} + +let wrapper = { + url: "moz-extension://b66e3509-cdb3-44f6-8eb8-c8b39b3a1d27/", + + cloneScope: global, + + checkLoadURL(url) { + return !url.startsWith("chrome:"); + }, + + preprocessors: { + localize(value, context) { + return value.replace(/__MSG_(.*?)__/g, (m0, m1) => `${m1.toUpperCase()}`); + }, + }, + + logError(message) { + talliedErrors.push(message); + }, + + hasPermission(permission) { + return permissions.has(permission); + }, + + shouldInject(ns, name) { + return name != "do-not-inject"; + }, + + getImplementation(namespace, name) { + return new TallyingAPIImplementation(namespace, name); + }, +}; + +add_task(async function() { + let baseSchemas = new Map([["resource://schemas/base.json", baseSchemaJSON]]); + let experimentSchemas = new Map([ + ["resource://experiment-foo/schema.json", experimentFooJSON], + ["resource://experiment-bar/schema.json", experimentBarJSON], + ]); + + let baseSchema = new SchemaRoot(null, baseSchemas); + let schema = new SchemaRoot(baseSchema, experimentSchemas); + + baseSchema.parseSchemas(); + schema.parseSchemas(); + + let root = {}; + let base = {}; + + tallied = null; + + baseSchema.inject(base, wrapper); + schema.inject(root, wrapper); + + equal(typeof base.base, "object", "base.base exists"); + equal(typeof root.base, "object", "root.base exists"); + equal(typeof base.experiments, "undefined", "base.experiments exists not"); + equal(typeof root.experiments, "object", "root.experiments exists"); + equal(typeof root.experiments.foo, "object", "root.experiments.foo exists"); + equal(typeof root.experiments.bar, "object", "root.experiments.bar exists"); + + equal(tallied, null); + + equal(root.base.PROP1, 42, "root.base.PROP1"); + equal(base.base.PROP1, 42, "root.base.PROP1"); + + root.base.foo("value2"); + verify("call", "base", "foo", ["value2"]); + + base.base.foo("value3"); + verify("call", "base", "foo", ["value3"]); + + root.experiments.foo.foo("foo2", "value1"); + verify("call", "experiments.foo", "foo", ["foo2", "value1"]); + + root.experiments.bar.bar("bar2", "value1"); + verify("call", "experiments.bar", "bar", ["bar2", "value1"]); + + Assert.throws( + () => root.base.foo("Meh."), + /Type error for parameter arg1/, + "root.base.foo()" + ); + + Assert.throws( + () => base.base.foo("Meh."), + /Type error for parameter arg1/, + "base.base.foo()" + ); + + Assert.throws( + () => root.experiments.foo.foo("Meh."), + /Incorrect argument types/, + "root.experiments.foo.foo()" + ); + + Assert.throws( + () => root.experiments.bar.bar("Meh."), + /Incorrect argument types/, + "root.experiments.bar.bar()" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js new file mode 100644 index 0000000000..626d8de22d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shadowdom.js @@ -0,0 +1,59 @@ +"use strict"; + +// ExtensionContent.jsm needs to know when it's running from xpcshell, +// to use the right timeout for content scripts executed at document_idle. +ExtensionTestUtils.mockAppInfo(); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_contentscript_shadowDOM() { + function backgroundScript() { + browser.test.assertTrue( + "openOrClosedShadowRoot" in document.documentElement, + "Should have openOrClosedShadowRoot in Element in background script." + ); + } + + function contentScript() { + let host = document.getElementById("host"); + browser.test.assertTrue( + "openOrClosedShadowRoot" in host, + "Should have openOrClosedShadowRoot in Element." + ); + let shadowRoot = host.openOrClosedShadowRoot; + browser.test.assertEq( + shadowRoot.mode, + "closed", + "Should have closed ShadowRoot." + ); + browser.test.sendMessage("contentScript"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://*/*/file_shadowdom.html"], + js: ["content_script.js"], + }, + ], + }, + background: backgroundScript, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_shadowdom.html` + ); + await extension.awaitMessage("contentScript"); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js new file mode 100644 index 0000000000..3952cefb07 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shared_workers.js @@ -0,0 +1,40 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This test attemps to verify that: +// - SharedWorkers can be created and successfully spawned by web extensions +// when web-extensions run in their own child process. +add_task(async function test_spawn_shared_worker() { + if (!WebExtensionPolicy.useRemoteWebExtensions) { + // Ensure RemoteWorkerService has been initialized in the main + // process. + Services.obs.notifyObservers(null, "profile-after-change"); + } + + const background = async function() { + const worker = new SharedWorker("worker.js"); + await new Promise(resolve => { + worker.port.onmessage = resolve; + worker.port.postMessage("bgpage->worker"); + }); + browser.test.sendMessage("test-shared-worker:done"); + }; + + const extension = ExtensionTestUtils.loadExtension({ + background, + files: { + "worker.js": function() { + self.onconnect = evt => { + const port = evt.ports[0]; + port.onmessage = () => port.postMessage("worker-reply"); + }; + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("test-shared-worker:done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js new file mode 100644 index 0000000000..8221219a38 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_shutdown_cleanup.js @@ -0,0 +1,42 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { GlobalManager } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null +); + +add_task(async function test_global_manager_shutdown_cleanup() { + equal( + GlobalManager.initialized, + false, + "GlobalManager start as not initialized" + ); + + function background() { + browser.test.notifyPass("background page loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + + await extension.startup(); + await extension.awaitFinish("background page loaded"); + + equal( + GlobalManager.initialized, + true, + "GlobalManager has been initialized once an extension is started" + ); + + await extension.unload(); + + equal( + GlobalManager.initialized, + false, + "GlobalManager has been uninitialized once all the webextensions have been stopped" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_simple.js b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js new file mode 100644 index 0000000000..7fd75eb088 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_simple.js @@ -0,0 +1,111 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_simple() { + let extensionData = { + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.unload(); +}); + +add_task(async function test_manifest_V3_disabled() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", false); + let extensionData = { + manifest: { + manifest_version: 3, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await Assert.rejects( + extension.startup(), + /Unsupported manifest version: 3/, + "manifest V3 cannot be loaded" + ); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_manifest_V3_enabled() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let extensionData = { + manifest: { + manifest_version: 3, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + equal(extension.extension.manifest.manifest_version, 3, "manifest V3 loads"); + await extension.unload(); + Services.prefs.clearUserPref("extensions.manifestV3.enabled"); +}); + +add_task(async function test_background() { + function background() { + browser.test.log("running background script"); + + browser.test.onMessage.addListener((x, y) => { + browser.test.assertEq(x, 10, "x is 10"); + browser.test.assertEq(y, 20, "y is 20"); + + browser.test.notifyPass("background test passed"); + }); + + browser.test.sendMessage("running", 1); + } + + let extensionData = { + background, + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let [, x] = await Promise.all([ + extension.startup(), + extension.awaitMessage("running"), + ]); + equal(x, 1, "got correct value from extension"); + + extension.sendMessage(10, 20); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task(async function test_extensionTypes() { + let extensionData = { + background: function() { + browser.test.assertEq( + typeof browser.extensionTypes, + "object", + "browser.extensionTypes exists" + ); + browser.test.assertEq( + typeof browser.extensionTypes.RunAt, + "object", + "browser.extensionTypes.RunAt exists" + ); + browser.test.notifyPass("extentionTypes test passed"); + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js new file mode 100644 index 0000000000..df51fa9abf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startupData.js @@ -0,0 +1,55 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "1" +); + +// Tests that startupData is persisted and is available at startup +add_task(async function test_startupData() { + await AddonTestUtils.promiseStartupManager(); + + let wrapper = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + }); + await wrapper.startup(); + + let { extension } = wrapper; + + deepEqual( + extension.startupData, + {}, + "startupData for a new extension defaults to empty object" + ); + + const DATA = { test: "i am some startup data" }; + extension.startupData = DATA; + extension.saveStartupData(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + ({ extension } = wrapper); + deepEqual(extension.startupData, DATA, "startupData is present on restart"); + + const DATA2 = { other: "this is different data" }; + extension.startupData = DATA2; + extension.saveStartupData(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + ({ extension } = wrapper); + deepEqual( + extension.startupData, + DATA2, + "updated startupData is present on restart" + ); + + await wrapper.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js new file mode 100644 index 0000000000..c21458e5a1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_cache.js @@ -0,0 +1,172 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonManager } = ChromeUtils.import( + "resource://gre/modules/AddonManager.jsm" +); +const { Preferences } = ChromeUtils.import( + "resource://gre/modules/Preferences.jsm" +); + +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +const ADDON_ID = "test-startup-cache@xpcshell.mozilla.org"; + +function makeExtension(opts) { + return { + useAddonManager: "permanent", + + manifest: { + version: opts.version, + applications: { gecko: { id: ADDON_ID } }, + + name: "__MSG_name__", + + default_locale: "en_US", + }, + + files: { + "_locales/en_US/messages.json": { + name: { + message: `en-US ${opts.version}`, + description: "Name.", + }, + }, + "_locales/fr/messages.json": { + name: { + message: `fr ${opts.version}`, + description: "Name.", + }, + }, + }, + + background() { + browser.test.onMessage.addListener(msg => { + if (msg === "get-manifest") { + browser.test.sendMessage("manifest", browser.runtime.getManifest()); + } + }); + }, + }; +} + +add_task(async function() { + Preferences.set("extensions.logging.enabled", false); + await AddonTestUtils.promiseStartupManager(); + + // Install langpacks to get proper locale startup. + let langpack = { + "manifest.json": { + name: "test Language Pack", + version: "1.0", + manifest_version: 2, + applications: { + gecko: { + id: "@test-langpack", + strict_min_version: "42.0", + strict_max_version: "42.0", + }, + }, + langpack_id: "fr", + languages: { + fr: { + chrome_resources: { + global: "chrome/fr/locale/fr/global/", + }, + version: "20171001190118", + }, + }, + sources: { + browser: { + base_path: "browser/", + }, + }, + }, + }; + + let [, { addon }] = await Promise.all([ + TestUtils.topicObserved("webextension-langpack-startup"), + AddonTestUtils.promiseInstallXPI(langpack), + ]); + + let extension = ExtensionTestUtils.loadExtension( + makeExtension({ version: "1.0" }) + ); + + function getManifest() { + extension.sendMessage("get-manifest"); + return extension.awaitMessage("manifest"); + } + + // At the moment extension language negotiation is tied to Firefox language + // negotiation result. That means that to test an extension in `fr`, we need + // to mock `fr` being available in Firefox and then request it. + // + // In the future, we should provide some way for tests to decouple their + // language selection from that of Firefox. + ok(Services.locale.availableLocales.includes("fr"), "fr locale is avialable"); + + await extension.startup(); + + equal(extension.version, "1.0", "Expected extension version"); + let manifest = await getManifest(); + equal(manifest.name, "en-US 1.0", "Got expected manifest name"); + + info("Restart and re-check"); + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.0", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.0", "Got expected manifest name"); + + info("Change locale to 'fr' and restart"); + Services.locale.requestedLocales = ["fr"]; + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.0", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "fr 1.0", "Got expected manifest name"); + + info("Update to version 1.1"); + await extension.upgrade(makeExtension({ version: "1.1" })); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "fr 1.1", "Got expected manifest name"); + + info("Change locale to 'en-US' and restart"); + Services.locale.requestedLocales = ["en-US"]; + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + equal(extension.version, "1.1", "Expected extension version"); + manifest = await getManifest(); + equal(manifest.name, "en-US 1.1", "Got expected manifest name"); + + info("uninstall locale 'fr'"); + addon = await AddonManager.getAddonByID("@test-langpack"); + await addon.uninstall(); + ok(!Services.locale.availableLocales.includes("fr"), "fr locale is removed"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js new file mode 100644 index 0000000000..691232479d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_perf.js @@ -0,0 +1,73 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const STARTUP_APIS = ["backgroundPage"]; + +const STARTUP_MODULES = [ + "resource://gre/modules/Extension.jsm", + "resource://gre/modules/ExtensionCommon.jsm", + "resource://gre/modules/ExtensionParent.jsm", + // FIXME: This is only loaded at startup for new extension installs. + // Otherwise the data comes from the startup cache. We should test for + // this. + "resource://gre/modules/ExtensionPermissions.jsm", + "resource://gre/modules/ExtensionProcessScript.jsm", + "resource://gre/modules/ExtensionUtils.jsm", + "resource://gre/modules/ExtensionTelemetry.jsm", +]; + +if (!Services.prefs.getBoolPref("extensions.webextensions.remote")) { + STARTUP_MODULES.push( + "resource://gre/modules/ExtensionChild.jsm", + "resource://gre/modules/ExtensionPageChild.jsm" + ); +} + +if (AppConstants.MOZ_APP_NAME == "thunderbird") { + STARTUP_MODULES.push( + "resource://gre/modules/ExtensionChild.jsm", + "resource://gre/modules/ExtensionContent.jsm", + "resource://gre/modules/ExtensionPageChild.jsm" + ); +} + +AddonTestUtils.init(this); + +// Tests that only the minimal set of API scripts and modules are loaded at +// startup for a simple extension. +add_task(async function test_loaded_scripts() { + await ExtensionTestUtils.startAddonManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + background() {}, + manifest: {}, + }); + + await extension.startup(); + + const { apiManager } = ExtensionParent; + + const loadedAPIs = Array.from(apiManager.modules.values()) + .filter(m => m.loaded || m.asyncLoaded) + .map(m => m.namespaceName); + + deepEqual( + loadedAPIs.sort(), + STARTUP_APIS, + "No extra APIs should be loaded at startup for a simple extension" + ); + + let loadedModules = Cu.loadedModules.filter(url => + url.startsWith("resource://gre/modules/Extension") + ); + + deepEqual( + loadedModules.sort(), + STARTUP_MODULES.sort(), + "No extra extension modules should be loaded at startup for a simple extension" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js new file mode 100644 index 0000000000..5ebe4c5230 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_startup_request_handler.js @@ -0,0 +1,64 @@ +"use strict"; + +function delay(time) { + return new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, time); + }); +} + +const { Extension } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" +); + +add_task(async function test_startup_request_handler() { + const ID = "request-startup@xpcshell.mozilla.org"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: ID } }, + }, + + files: { + "meh.txt": "Meh.", + }, + }); + + let ready = false; + let resolvePromise; + let promise = new Promise(resolve => { + resolvePromise = resolve; + }); + promise.then(() => { + ready = true; + }); + + let origInitLocale = Extension.prototype.initLocale; + Extension.prototype.initLocale = async function initLocale() { + await promise; + return origInitLocale.call(this); + }; + + let startupPromise = extension.startup(); + + await delay(0); + let policy = WebExtensionPolicy.getByID(ID); + let url = policy.getURL("meh.txt"); + + let resp = ExtensionTestUtils.fetch(url, url); + resp.then(() => { + ok(ready, "Shouldn't get response before extension is ready"); + }); + + await delay(2000); + + resolvePromise(); + await startupPromise; + + let body = await resp; + equal(body, "Meh.", "Got the correct response"); + + await extension.unload(); + + Extension.prototype.initLocale = origInitLocale; +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js new file mode 100644 index 0000000000..b677110a47 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_local.js @@ -0,0 +1,39 @@ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageIDB.jsm" +); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_local_file_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () => + test_contentscript_storage("local") + ); +}); + +add_task(async function test_contentscript_storage_local_idb_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_contentscript_storage("local") + ); +}); + +add_task(async function test_contentscript_storage_local_idb_no_bytes_in_use() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_contentscript_storage_area_no_bytes_in_use("local") + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js new file mode 100644 index 0000000000..6b1695417d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync.js @@ -0,0 +1,31 @@ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage("sync") + ); +}); + +add_task(async function test_contentscript_bytes_in_use_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage_area_with_bytes_in_use("sync", true) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js new file mode 100644 index 0000000000..92ec405520 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_content_sync_kinto.js @@ -0,0 +1,31 @@ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +PromiseTestUtils.allowMatchingRejectionsGlobally( + /WebExtension context not found/ +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +// The storage API in content scripts should behave identical to the storage API +// in background pages. + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_contentscript_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage("sync") + ); +}); + +add_task(async function test_contentscript_storage_no_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_contentscript_storage_area_with_bytes_in_use("sync", false) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js new file mode 100644 index 0000000000..90d4740bf9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_idb_data_migration.js @@ -0,0 +1,787 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test file verifies various scenarios related to the data migration +// from the JSONFile backend to the IDB backend. + +AddonTestUtils.init(this); + +// Create appInfo before importing any other jsm file, to prevent +// Services.appinfo to be cached before an appInfo.version is +// actually defined (which prevent failures to be triggered when +// the test run in a non nightly build). +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { getTrimmedString } = ChromeUtils.import( + "resource://gre/modules/ExtensionTelemetry.jsm" +); +const { ExtensionStorage } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorage.jsm" +); +const { ExtensionStorageIDB } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageIDB.jsm" +); +const { TelemetryController } = ChromeUtils.import( + "resource://gre/modules/TelemetryController.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + OS: "resource://gre/modules/osfile.jsm", +}); + +const { promiseShutdownManager, promiseStartupManager } = AddonTestUtils; + +const { + IDB_MIGRATED_PREF_BRANCH, + IDB_MIGRATE_RESULT_HISTOGRAM, +} = ExtensionStorageIDB; +const CATEGORIES = ["success", "failure"]; +const EVENT_CATEGORY = "extensions.data"; +const EVENT_OBJECT = "storageLocal"; +const EVENT_METHOD = "migrateResult"; +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; +const TELEMETRY_EVENTS_FILTER = { + category: "extensions.data", + method: "migrateResult", + object: "storageLocal", +}; + +async function createExtensionJSONFileWithData(extensionId, data) { + await ExtensionStorage.set(extensionId, data); + const jsonFile = await ExtensionStorage.getFile(extensionId); + await jsonFile._save(); + const oldStorageFilename = ExtensionStorage.getStorageFile(extensionId); + equal( + await OS.File.exists(oldStorageFilename), + true, + "The old json file has been created" + ); + + return { jsonFile, oldStorageFilename }; +} + +function clearMigrationHistogram() { + const histogram = Services.telemetry.getHistogramById( + IDB_MIGRATE_RESULT_HISTOGRAM + ); + histogram.clear(); + equal( + histogram.snapshot().sum, + 0, + `No data recorded for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}` + ); +} + +function assertMigrationHistogramCount(category, expectedCount) { + const histogram = Services.telemetry.getHistogramById( + IDB_MIGRATE_RESULT_HISTOGRAM + ); + + equal( + histogram.snapshot().values[CATEGORIES.indexOf(category)], + expectedCount, + `Got the expected count on category "${category}" for histogram ${IDB_MIGRATE_RESULT_HISTOGRAM}` + ); +} + +function assertTelemetryEvents(expectedEvents) { + TelemetryTestUtils.assertEvents(expectedEvents, { + category: EVENT_CATEGORY, + method: EVENT_METHOD, + object: EVENT_OBJECT, + }); +} + +add_task(async function setup() { + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true); + + await promiseStartupManager(); + + // Telemetry test setup needed to ensure that the builtin events are defined + // and they can be collected and verified. + await TelemetryController.testSetup(); + + // This is actually only needed on Android, because it does not properly support unified telemetry + // and so, if not enabled explicitly here, it would make these tests to fail when running on a + // non-Nightly build. + const oldCanRecordBase = Services.telemetry.canRecordBase; + Services.telemetry.canRecordBase = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordBase = oldCanRecordBase; + }); + + // Clear any telemetry events collected so far. + Services.telemetry.clearEvents(); +}); + +// Test that for newly installed extension the IDB backend is enabled without +// any data migration. +add_task(async function test_no_migration_for_newly_installed_extensions() { + const EXTENSION_ID = "test-no-data-migration@mochi.test"; + + await createExtensionJSONFileWithData(EXTENSION_ID, { + test_old_data: "test_old_value", + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXTENSION_ID } }, + }, + async background() { + const data = await browser.storage.local.get(); + browser.test.assertEq( + Object.keys(data).length, + 0, + "Expect the storage.local store to be empty" + ); + browser.test.sendMessage("test-stored-data:done"); + }, + }); + + await extension.startup(); + equal( + ExtensionStorageIDB.isMigratedExtension(extension), + true, + "The newly installed test extension is marked as migrated" + ); + await extension.awaitMessage("test-stored-data:done"); + await extension.unload(); + + // Verify that no data migration have been needed on the newly installed + // extension, by asserting that no telemetry events has been collected. + await TelemetryTestUtils.assertEvents([], TELEMETRY_EVENTS_FILTER); +}); + +// Test that the data migration is still running for a newly installed extension +// if keepStorageOnUninstall is true. +add_task(async function test_data_migration_on_keep_storage_on_uninstall() { + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + + // Store some fake data in the storage.local file backend before starting the extension. + const EXTENSION_ID = "new-extension-on-keep-storage-on-uninstall@mochi.test"; + await createExtensionJSONFileWithData(EXTENSION_ID, { + test_key_string: "test_value", + }); + + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXTENSION_ID } }, + }, + async background() { + const storedData = await browser.storage.local.get(); + browser.test.assertEq( + "test_value", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + browser.test.sendMessage("storage-local-data-migrated"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("storage-local-data-migrated"); + equal( + ExtensionStorageIDB.isMigratedExtension(extension), + true, + "The newly installed test extension is marked as migrated" + ); + await extension.unload(); + + // Verify that the expected telemetry has been recorded. + await TelemetryTestUtils.assertEvents( + [ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ], + TELEMETRY_EVENTS_FILTER + ); + + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); +}); + +// Test that the old data is migrated successfully to the new storage backend +// and that the original JSONFile has been renamed. +add_task(async function test_storage_local_data_migration() { + const EXTENSION_ID = "extension-to-be-migrated@mozilla.org"; + + // Keep the extension storage and the uuid on uninstall, to verify that no telemetry events + // are being sent for an already migrated extension. + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + + const data = { + test_key_string: "test_value1", + test_key_number: 1000, + test_nested_data: { + nested_key: true, + }, + }; + + // Store some fake data in the storage.local file backend before starting the extension. + const { oldStorageFilename } = await createExtensionJSONFileWithData( + EXTENSION_ID, + data + ); + + async function background() { + const storedData = await browser.storage.local.get(); + + browser.test.assertEq( + "test_value1", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + browser.test.assertEq( + 1000, + storedData.test_key_number, + "Got the expected data after the storage.local data migration" + ); + browser.test.assertEq( + true, + storedData.test_nested_data.nested_key, + "Got the expected data after the storage.local data migration" + ); + + browser.test.sendMessage("storage-local-data-migrated"); + } + + clearMigrationHistogram(); + + let extensionDefinition = { + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionDefinition); + + // Install the extension while the storage.local IDB backend is disabled. + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, false); + await extension.startup(); + + ok( + !ExtensionStorageIDB.isMigratedExtension(extension), + "The test extension should be using the JSONFile backend" + ); + + // Enabled the storage.local IDB backend and upgrade the extension. + Services.prefs.setBoolPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF, true); + await extension.upgrade({ + ...extensionDefinition, + background, + }); + + await extension.awaitMessage("storage-local-data-migrated"); + + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The test extension should be using the IndexedDB backend" + ); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + + equal( + await idbConn.isEmpty(extension.extension), + false, + "Data stored in the ExtensionStorageIDB backend as expected" + ); + + equal( + await OS.File.exists(oldStorageFilename), + false, + "The old json storage file name should not exist anymore" + ); + + equal( + await OS.File.exists(`${oldStorageFilename}.migrated`), + true, + "The old json storage file name should have been renamed as .migrated" + ); + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected` + ); + + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `${IDB_MIGRATED_PREF_BRANCH} should still be true on keepStorageOnUninstall=true` + ); + + // Upgrade the extension and check that no telemetry events are being sent + // for an already migrated extension. + await extension.upgrade({ + ...extensionDefinition, + background, + }); + + await extension.awaitMessage("storage-local-data-migrated"); + + // The histogram values are unmodified. + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + // No new telemetry events recorded for the extension. + const snapshot = Services.telemetry.snapshotEvents( + Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS, + true + ); + const filterByCategory = ([timestamp, category]) => + category === EVENT_CATEGORY; + + ok( + !snapshot.parent || snapshot.parent.filter(filterByCategory).length === 0, + "No telemetry events should be recorded for an already migrated extension" + ); + + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, false); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, false); + + await extension.unload(); + + equal( + Services.prefs.getPrefType(`${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`), + Services.prefs.PREF_INVALID, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference has been cleared on addon uninstall` + ); +}); + +// Test that the extensionId included in the telemetry event is being trimmed down to 80 chars +// as expected. +add_task(async function test_extensionId_trimmed_in_telemetry_event() { + // Generated extensionId in email-like format, longer than 80 chars. + const EXTENSION_ID = `long.extension.id@${Array(80) + .fill("a") + .join("")}`; + + const data = { test_key_string: "test_value" }; + + // Store some fake data in the storage.local file backend before starting the extension. + await createExtensionJSONFileWithData(EXTENSION_ID, data); + + async function background() { + const storedData = await browser.storage.local.get("test_key_string"); + + browser.test.assertEq( + "test_value", + storedData.test_key_string, + "Got the expected data after the storage.local data migration" + ); + + browser.test.sendMessage("storage-local-data-migrated"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated"); + + const expectedTrimmedExtensionId = getTrimmedString(EXTENSION_ID); + + equal( + expectedTrimmedExtensionId.length, + 80, + "The trimmed version of the extensionId should be 80 chars long" + ); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: expectedTrimmedExtensionId, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + await extension.unload(); +}); + +// Test that if the old JSONFile data file is corrupted and the old data +// can't be successfully migrated to the new storage backend, then: +// - the new storage backend for that extension is still initialized and enabled +// - any new data is being stored in the new backend +// - the old file is being renamed (with the `.corrupted` suffix that JSONFile.jsm +// adds when it fails to load the data file) and still available on disk. +add_task(async function test_storage_local_corrupted_data_migration() { + const EXTENSION_ID = "extension-corrupted-data-migration@mozilla.org"; + + const invalidData = `{"test_key_string": "test_value1"`; + const oldStorageFilename = ExtensionStorage.getStorageFile(EXTENSION_ID); + + const profileDir = OS.Constants.Path.profileDir; + await OS.File.makeDir( + OS.Path.join(profileDir, "browser-extension-data", EXTENSION_ID), + { from: profileDir, ignoreExisting: true } + ); + + // Write the json file with some invalid data. + await OS.File.writeAtomic(oldStorageFilename, invalidData, { flush: true }); + equal( + await OS.File.read(oldStorageFilename, { encoding: "utf-8" }), + invalidData, + "The old json file has been overwritten with invalid data" + ); + + async function background() { + const storedData = await browser.storage.local.get(); + + browser.test.assertEq( + Object.keys(storedData).length, + 0, + "No data should be found on invalid data migration" + ); + + await browser.storage.local.set({ + test_key_string_on_IDBBackend: "expected-value", + }); + + browser.test.sendMessage("storage-local-data-migrated-and-set"); + } + + clearMigrationHistogram(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated-and-set"); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + + equal( + await idbConn.isEmpty(extension.extension), + false, + "Data stored in the ExtensionStorageIDB backend as expected" + ); + + equal( + await OS.File.exists(`${oldStorageFilename}.corrupt`), + true, + "The old json storage should still be available if failed to be read" + ); + + // The extension is still migrated successfully to the new backend if the file from the + // original json file was corrupted. + + equal( + Services.prefs.getBoolPref( + `${IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID}`, + false + ), + true, + `Got the ${IDB_MIGRATED_PREF_BRANCH} preference set to true as expected` + ); + + assertMigrationHistogramCount("success", 1); + assertMigrationHistogramCount("failure", 0); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "IndexedDB", + data_migrated: "y", + has_jsonfile: "y", + has_olddata: "n", + }, + }, + ]); + + await extension.unload(); +}); + +// Test that if the data migration fails to store the old data into the IndexedDB backend +// then the expected telemetry histogram is being updated. +add_task(async function test_storage_local_data_migration_failure() { + const EXTENSION_ID = "extension-data-migration-failure@mozilla.org"; + + // Create the file under the expected directory tree. + const { + jsonFile, + oldStorageFilename, + } = await createExtensionJSONFileWithData(EXTENSION_ID, {}); + + // Store a fake invalid value which is going to fail to be saved into IndexedDB + // (because it can't be cloned and it is going to raise a DataCloneError), which + // will trigger a data migration failure that we expect to increment the related + // telemetry histogram. + jsonFile.data.set("fake_invalid_key", new Error()); + + async function background() { + await browser.storage.local.set({ + test_key_string_on_JSONFileBackend: "expected-value", + }); + browser.test.sendMessage("storage-local-data-migrated-and-set"); + } + + clearMigrationHistogram(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("storage-local-data-migrated-and-set"); + + const storagePrincipal = ExtensionStorageIDB.getStoragePrincipal( + extension.extension + ); + + const idbConn = await ExtensionStorageIDB.open(storagePrincipal); + equal( + await idbConn.isEmpty(extension.extension), + true, + "No data stored in the ExtensionStorageIDB backend as expected" + ); + equal( + await OS.File.exists(oldStorageFilename), + true, + "The old json storage should still be available if failed to be read" + ); + + await extension.unload(); + + assertTelemetryEvents([ + { + method: "migrateResult", + value: EXTENSION_ID, + extra: { + backend: "JSONFile", + data_migrated: "n", + error_name: "DataCloneError", + has_jsonfile: "y", + has_olddata: "y", + }, + }, + ]); + + assertMigrationHistogramCount("success", 0); + assertMigrationHistogramCount("failure", 1); +}); + +add_task(async function test_migration_aborted_on_shutdown() { + const EXTENSION_ID = "test-migration-aborted-on-shutdown@mochi.test"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }); + + await extension.startup(); + + equal( + extension.extension.hasShutdown, + false, + "The extension is still running" + ); + + await extension.unload(); + equal(extension.extension.hasShutdown, true, "The extension has shutdown"); + + // Trigger a data migration after the extension has been unloaded. + const result = await ExtensionStorageIDB.selectBackend({ + extension: extension.extension, + }); + Assert.deepEqual( + result, + { backendEnabled: false }, + "Expect migration to have been aborted" + ); + TelemetryTestUtils.assertEvents( + [ + { + value: EXTENSION_ID, + extra: { + backend: "JSONFile", + error_name: "DataMigrationAbortedError", + }, + }, + ], + TELEMETRY_EVENTS_FILTER + ); +}); + +add_task(async function test_storage_local_data_migration_clear_pref() { + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); + Services.prefs.clearUserPref(LEAVE_UUID_PREF); + Services.prefs.clearUserPref(ExtensionStorageIDB.BACKEND_ENABLED_PREF); + await promiseShutdownManager(); + await TelemetryController.testShutdown(); +}); + +add_task(async function setup_quota_manager_testing_prefs() { + Services.prefs.setBoolPref("dom.quotaManager.testing", true); + Services.prefs.setIntPref( + "dom.quotaManager.temporaryStorage.fixedLimit", + 100 + ); + await promiseQuotaManagerServiceReset(); +}); + +add_task( + // TODO: temporarily disabled because it currently perma-fails on + // android builds (Bug 1564871) + { skip_if: () => AppConstants.platform === "android" }, + // eslint-disable-next-line no-use-before-define + test_quota_exceeded_while_migrating_data +); +async function test_quota_exceeded_while_migrating_data() { + const EXT_ID = "test-data-migration-stuck@mochi.test"; + const dataSize = 1000 * 1024; + + await createExtensionJSONFileWithData(EXT_ID, { + data: new Array(dataSize).fill("x").join(""), + }); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXT_ID } }, + }, + background() { + browser.test.onMessage.addListener(async (msg, dataSize) => { + if (msg !== "verify-stored-data") { + return; + } + const res = await browser.storage.local.get(); + browser.test.assertEq( + res.data && res.data.length, + dataSize, + "Got the expected data" + ); + browser.test.sendMessage("verify-stored-data:done"); + }); + + browser.test.sendMessage("bg-page:ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg-page:ready"); + + extension.sendMessage("verify-stored-data", dataSize); + await extension.awaitMessage("verify-stored-data:done"); + + await ok( + !ExtensionStorageIDB.isMigratedExtension(extension), + "The extension falls back to the JSONFile backend because of the migration failure" + ); + await extension.unload(); + + TelemetryTestUtils.assertEvents( + [ + { + value: EXT_ID, + extra: { + backend: "JSONFile", + error_name: "QuotaExceededError", + }, + }, + ], + TELEMETRY_EVENTS_FILTER + ); + + Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit"); + await promiseQuotaManagerServiceClear(); + Services.prefs.clearUserPref("dom.quotaManager.testing"); +} diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js new file mode 100644 index 0000000000..a74528db7d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_local.js @@ -0,0 +1,73 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionStorageIDB", + "resource://gre/modules/ExtensionStorageIDB.jsm" +); + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_local_cache_invalidation() { + function background(checkGet) { + browser.test.onMessage.addListener(async msg => { + if (msg === "set-initial") { + await browser.storage.local.set({ + "test-prop1": "value1", + "test-prop2": "value2", + }); + browser.test.sendMessage("set-initial-done"); + } else if (msg === "check") { + await checkGet("local", "test-prop1", "value1"); + await checkGet("local", "test-prop2", "value2"); + browser.test.sendMessage("check-done"); + } + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})(${checkGetImpl})`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("set-initial"); + await extension.awaitMessage("set-initial-done"); + + Services.obs.notifyObservers(null, "extension-invalidate-storage-cache"); + + extension.sendMessage("check"); + await extension.awaitMessage("check-done"); + + await extension.unload(); +}); + +add_task(function test_storage_local_file_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], () => + test_background_page_storage("local") + ); +}); + +add_task(function test_storage_local_idb_backend() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_background_page_storage("local") + ); +}); + +add_task(function test_storage_local_idb_bytes_in_use() { + return runWithPrefs([[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], () => + test_background_storage_area_no_bytes_in_use("local") + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js new file mode 100644 index 0000000000..b35e4240c4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed.js @@ -0,0 +1,170 @@ +"use strict"; + +XPCOMUtils.defineLazyModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.jsm", + OS: "resource://gre/modules/osfile.jsm", +}); + +const MANIFEST = { + name: "test-storage-managed@mozilla.com", + description: "", + type: "storage", + data: { + null: null, + str: "hello", + obj: { + a: [2, 3], + b: true, + }, + }, +}; + +AddonTestUtils.init(this); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); + + let tmpDir = FileUtils.getDir("TmpD", ["native-manifests"]); + tmpDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let dirProvider = { + getFile(property) { + if (property.endsWith("NativeManifests")) { + return tmpDir.clone(); + } + }, + }; + Services.dirsvc.registerProvider(dirProvider); + + let typeSlug = + AppConstants.platform === "linux" ? "managed-storage" : "ManagedStorage"; + OS.File.makeDir(OS.Path.join(tmpDir.path, typeSlug)); + + let path = OS.Path.join(tmpDir.path, typeSlug, `${MANIFEST.name}.json`); + await OS.File.writeAtomic(path, JSON.stringify(MANIFEST)); + + let registry; + if (AppConstants.platform === "win") { + registry = new MockRegistry(); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `Software\\\Mozilla\\\ManagedStorage\\${MANIFEST.name}`, + "", + path + ); + } + + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + tmpDir.remove(true); + if (registry) { + registry.shutdown(); + } + }); +}); + +add_task(async function test_storage_managed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: MANIFEST.name } }, + permissions: ["storage"], + }, + + async background() { + await browser.test.assertRejects( + browser.storage.managed.set({ a: 1 }), + /storage.managed is read-only/, + "browser.storage.managed.set() rejects because it's read only" + ); + + await browser.test.assertRejects( + browser.storage.managed.remove("str"), + /storage.managed is read-only/, + "browser.storage.managed.remove() rejects because it's read only" + ); + + await browser.test.assertRejects( + browser.storage.managed.clear(), + /storage.managed is read-only/, + "browser.storage.managed.clear() rejects because it's read only" + ); + + browser.test.sendMessage( + "results", + await Promise.all([ + browser.storage.managed.get(), + browser.storage.managed.get("str"), + browser.storage.managed.get(["null", "obj"]), + browser.storage.managed.get({ str: "a", num: 2 }), + ]) + ); + }, + }); + + await extension.startup(); + deepEqual(await extension.awaitMessage("results"), [ + MANIFEST.data, + { str: "hello" }, + { null: null, obj: MANIFEST.data.obj }, + { str: "hello", num: 2 }, + ]); + await extension.unload(); +}); + +add_task(async function test_storage_managed_from_content_script() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: MANIFEST.name } }, + permissions: ["storage"], + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["*://*/*"], + run_at: "document_end", + }, + ], + }, + + files: { + "contentscript.js": async function() { + browser.test.sendMessage( + "results", + await browser.storage.managed.get() + ); + }, + }, + }); + + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + deepEqual(await extension.awaitMessage("results"), MANIFEST.data); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_manifest_not_found() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + + async background() { + await browser.test.assertRejects( + browser.storage.managed.get({ a: 1 }), + /Managed storage manifest not found/, + "browser.storage.managed.get() rejects when without manifest" + ); + + browser.test.notifyPass(); + }, + }); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js new file mode 100644 index 0000000000..d99956671d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_managed_policy.js @@ -0,0 +1,55 @@ +"use strict"; + +const PREF_DISABLE_SECURITY = + "security.turn_off_all_security_so_that_" + + "viruses_can_take_over_this_computer"; + +const { EnterprisePolicyTesting } = ChromeUtils.import( + "resource://testing-common/EnterprisePolicyTesting.jsm" +); + +// Setting PREF_DISABLE_SECURITY tells the policy engine that we are in testing +// mode and enables restarting the policy engine without restarting the browser. +Services.prefs.setBoolPref(PREF_DISABLE_SECURITY, true); +registerCleanupFunction(() => { + Services.prefs.clearUserPref(PREF_DISABLE_SECURITY); +}); + +// Load policy engine +Services.policies; // eslint-disable-line no-unused-expressions + +AddonTestUtils.init(this); + +add_task(async function test_storage_managed_policy() { + await ExtensionTestUtils.startAddonManager(); + + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + "3rdparty": { + Extensions: { + "test-storage-managed-policy@mozilla.com": { + string: "value", + }, + }, + }, + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { + gecko: { id: "test-storage-managed-policy@mozilla.com" }, + }, + permissions: ["storage"], + }, + + async background() { + let str = await browser.storage.managed.get("string"); + browser.test.sendMessage("results", str); + }, + }); + + await extension.startup(); + deepEqual(await extension.awaitMessage("results"), { string: "value" }); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js new file mode 100644 index 0000000000..b9dc8a0212 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_quota_exceeded_errors.js @@ -0,0 +1,82 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "ExtensionStorageIDB", + "resource://gre/modules/ExtensionStorageIDB.jsm" +); + +const LEAVE_STORAGE_PREF = "extensions.webextensions.keepStorageOnUninstall"; +const LEAVE_UUID_PREF = "extensions.webextensions.keepUuidOnUninstall"; + +AddonTestUtils.init(this); + +add_task(async function setup() { + // Ensure that the IDB backend is enabled. + Services.prefs.setBoolPref("ExtensionStorageIDB.BACKEND_ENABLED_PREF", true); + + Services.prefs.setBoolPref("dom.quotaManager.testing", true); + Services.prefs.setIntPref( + "dom.quotaManager.temporaryStorage.fixedLimit", + 100 + ); + await promiseQuotaManagerServiceReset(); + + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_storage_local_set_quota_exceeded_error() { + const EXT_ID = "test-quota-exceeded@mochi.test"; + + const extensionDef = { + manifest: { + permissions: ["storage"], + applications: { gecko: { id: EXT_ID } }, + }, + async background() { + const data = new Array(1000 * 1024).fill("x").join(""); + await browser.test.assertRejects( + browser.storage.local.set({ data }), + /QuotaExceededError/, + "Got a rejection with the expected error message" + ); + browser.test.sendMessage("data-stored"); + }, + }; + + Services.prefs.setBoolPref(LEAVE_STORAGE_PREF, true); + Services.prefs.setBoolPref(LEAVE_UUID_PREF, true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref(LEAVE_STORAGE_PREF); + Services.prefs.clearUserPref(LEAVE_UUID_PREF); + }); + + const extension = ExtensionTestUtils.loadExtension(extensionDef); + + // Run test on a test extension being migrated to the IDB backend. + await extension.startup(); + await extension.awaitMessage("data-stored"); + + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The extension has been successfully migrated to the IDB backend" + ); + await extension.unload(); + + // Run again on a test extension already already migrated to the IDB backend. + const extensionUpdated = ExtensionTestUtils.loadExtension(extensionDef); + await extensionUpdated.startup(); + ok( + ExtensionStorageIDB.isMigratedExtension(extension), + "The extension has been successfully migrated to the IDB backend" + ); + await extensionUpdated.awaitMessage("data-stored"); + + await extensionUpdated.unload(); + + Services.prefs.clearUserPref("dom.quotaManager.temporaryStorage.fixedLimit"); + await promiseQuotaManagerServiceClear(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js new file mode 100644 index 0000000000..38d1de29fa --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sanitizer.js @@ -0,0 +1,106 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "Sanitizer", + "resource:///modules/Sanitizer.jsm" +); + +async function test_sanitize_offlineApps(storageHelpersScript) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + background: { + scripts: ["storageHelpers.js", "background.js"], + }, + }, + files: { + "storageHelpers.js": storageHelpersScript, + "background.js": function() { + browser.test.onMessage.addListener(async (msg, args) => { + let result = {}; + switch (msg) { + case "set-storage-data": + await window.testWriteKey(...args); + break; + case "get-storage-data": + const value = await window.testReadKey(args[0]); + browser.test.assertEq(args[1], value, "Got the expected value"); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`, result); + }); + }, + }, + }); + + await extension.startup(); + + extension.sendMessage("set-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("set-storage-data:done"); + + await extension.sendMessage("get-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("get-storage-data:done"); + + info("Verify the extension data not cleared by offlineApps Sanitizer"); + await Sanitizer.sanitize(["offlineApps"]); + await extension.sendMessage("get-storage-data", ["aKey", "aValue"]); + await extension.awaitMessage("get-storage-data:done"); + + await extension.unload(); +} + +add_task(async function test_sanitize_offlineApps_extension_indexedDB() { + await test_sanitize_offlineApps(function indexedDBStorageHelpers() { + const getIDBStore = () => + new Promise(resolve => { + let dbreq = window.indexedDB.open("TestDB"); + dbreq.onupgradeneeded = () => + dbreq.result.createObjectStore("TestStore"); + dbreq.onsuccess = () => resolve(dbreq.result); + }); + + // Export writeKey and readKey storage test helpers. + window.testWriteKey = (k, v) => + getIDBStore().then(db => { + const tx = db.transaction("TestStore", "readwrite"); + const store = tx.objectStore("TestStore"); + return new Promise((resolve, reject) => { + tx.oncomplete = evt => resolve(evt.target.result); + tx.onerror = evt => reject(evt.target.error); + store.add(v, k); + }); + }); + window.testReadKey = k => + getIDBStore().then(db => { + const tx = db.transaction("TestStore"); + const store = tx.objectStore("TestStore"); + return new Promise((resolve, reject) => { + const req = store.get(k); + tx.oncomplete = evt => resolve(req.result); + tx.onerror = evt => reject(evt.target.error); + }); + }); + }); +}); + +add_task( + { + // Skip this test if LSNG is not enabled (because this test is only + // going to pass when nextgen local storage is being used). + skip_if: () => !Services.prefs.getBoolPref("dom.storage.next_gen"), + }, + async function test_sanitize_offlineApps_extension_localStorage() { + await test_sanitize_offlineApps(function indexedDBStorageHelpers() { + // Export writeKey and readKey storage test helpers. + window.testWriteKey = (k, v) => window.localStorage.setItem(k, v); + window.testReadKey = k => window.localStorage.getItem(k); + }); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js new file mode 100644 index 0000000000..ad821c5a07 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync.js @@ -0,0 +1,29 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", false); + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(test_config_flag_needed); + +add_task(test_sync_reloading_extensions_works); + +add_task(function test_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_page_storage("sync") + ); +}); + +add_task(test_storage_sync_requires_real_id); + +add_task(function test_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_storage_area_with_bytes_in_use("sync", true) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js new file mode 100644 index 0000000000..db7091db8d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto.js @@ -0,0 +1,2290 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a kinto-specific test... +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +do_get_profile(); // so we can use FxAccounts + +const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js"); +const { CommonUtils } = ChromeUtils.import( + "resource://services-common/utils.js" +); +const { + cleanUpForContext, + CollectionKeyEncryptionRemoteTransformer, + CryptoCollection, + ExtensionStorageSync, + idToKey, + keyToId, + KeyRingEncryptionRemoteTransformer, +} = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSyncKinto.jsm", + null +); +const { BulkKeyBundle } = ChromeUtils.import( + "resource://services-sync/keys.js" +); +const { FxAccountsKeys } = ChromeUtils.import( + "resource://gre/modules/FxAccountsKeys.jsm" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +const { createAppInfo, promiseStartupManager } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "69"); + +function handleCannedResponse(cannedResponse, request, response) { + response.setStatusLine( + null, + cannedResponse.status.status, + cannedResponse.status.statusText + ); + // send the headers + for (let headerLine of cannedResponse.sampleHeaders) { + let headerElements = headerLine.split(":"); + response.setHeader(headerElements[0], headerElements[1].trimLeft()); + } + response.setHeader("Date", new Date().toUTCString()); + + response.write(cannedResponse.responseBody); +} + +function collectionPath(collectionId) { + return `/buckets/default/collections/${collectionId}`; +} + +function collectionRecordsPath(collectionId) { + return `/buckets/default/collections/${collectionId}/records`; +} + +class KintoServer { + constructor() { + // Set up an HTTP Server + this.httpServer = new HttpServer(); + this.httpServer.start(-1); + + // Set<Object> corresponding to records that might be served. + // The format of these objects is defined in the documentation for #addRecord. + this.records = []; + + // Collections that we have set up access to (see `installCollection`). + this.collections = new Set(); + + // ETag to serve with responses + this.etag = 1; + + this.port = this.httpServer.identity.primaryPort; + + // POST requests we receive from the client go here + this.posts = []; + // DELETEd buckets will go here. + this.deletedBuckets = []; + // Anything in here will force the next POST to generate a conflict + this.conflicts = []; + // If this is true, reject the next request with a 401 + this.rejectNextAuthResponse = false; + this.failedAuths = []; + + this.installConfigPath(); + this.installBatchPath(); + this.installCatchAll(); + } + + clearPosts() { + this.posts = []; + } + + getPosts() { + return this.posts; + } + + getDeletedBuckets() { + return this.deletedBuckets; + } + + rejectNextAuthWith(response) { + this.rejectNextAuthResponse = response; + } + + checkAuth(request, response) { + equal(request.getHeader("Authorization"), "Bearer some-access-token"); + + if (this.rejectNextAuthResponse) { + response.setStatusLine(null, 401, "Unauthorized"); + response.write(this.rejectNextAuthResponse); + this.rejectNextAuthResponse = false; + this.failedAuths.push(request); + return true; + } + return false; + } + + installConfigPath() { + const configPath = "/v1/"; + const responseBody = JSON.stringify({ + settings: { batch_max_requests: 25 }, + url: `http://localhost:${this.port}/v1/`, + documentation: "https://kinto.readthedocs.org/", + version: "1.5.1", + commit: "cbc6f58", + hello: "kinto", + }); + const configResponse = { + sampleHeaders: [ + "Access-Control-Allow-Origin: *", + "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + "Content-Type: application/json; charset=UTF-8", + "Server: waitress", + ], + status: { status: 200, statusText: "OK" }, + responseBody: responseBody, + }; + + function handleGetConfig(request, response) { + if (request.method != "GET") { + dump(`ARGH, got ${request.method}\n`); + } + return handleCannedResponse(configResponse, request, response); + } + + this.httpServer.registerPathHandler(configPath, handleGetConfig); + } + + installBatchPath() { + const batchPath = "/v1/batch"; + + function handlePost(request, response) { + if (this.checkAuth(request, response)) { + return; + } + + let bodyStr = CommonUtils.readBytesFromInputStream( + request.bodyInputStream + ); + let body = JSON.parse(bodyStr); + let defaults = body.defaults; + for (let req of body.requests) { + let headers = Object.assign( + {}, + (defaults && defaults.headers) || {}, + req.headers + ); + this.posts.push(Object.assign({}, req, { headers })); + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + + let postResponse = { + responses: body.requests.map(req => { + let oneBody; + if (req.method == "DELETE") { + let id = req.path.match( + /^\/buckets\/default\/collections\/.+\/records\/(.+)$/ + )[1]; + oneBody = { + data: { + deleted: true, + id: id, + last_modified: this.etag, + }, + }; + } else { + oneBody = { + data: Object.assign({}, req.body.data, { + last_modified: this.etag, + }), + permissions: [], + }; + } + + return { + path: req.path, + status: 201, // FIXME -- only for new posts?? + headers: { ETag: 3000 }, // FIXME??? + body: oneBody, + }; + }), + }; + + if (this.conflicts.length) { + const nextConflict = this.conflicts.shift(); + if (!nextConflict.transient) { + this.records.push(nextConflict); + } + const { data } = nextConflict; + postResponse = { + responses: body.requests.map(req => { + return { + path: req.path, + status: 412, + headers: { ETag: this.etag }, // is this correct?? + body: { + details: { + existing: data, + }, + }, + }; + }), + }; + } + + response.write(JSON.stringify(postResponse)); + + // "sampleHeaders": [ + // "Access-Control-Allow-Origin: *", + // "Access-Control-Expose-Headers: Retry-After, Content-Length, Alert, Backoff", + // "Server: waitress", + // "Etag: \"4000\"" + // ], + } + + this.httpServer.registerPathHandler(batchPath, handlePost.bind(this)); + } + + installCatchAll() { + this.httpServer.registerPathHandler("/", (request, response) => { + dump( + `got request: ${request.method}:${request.path}?${request.queryString}\n` + ); + dump( + `${CommonUtils.readBytesFromInputStream(request.bodyInputStream)}\n` + ); + }); + } + + /** + * Add a record to those that can be served by this server. + * + * @param {Object} properties An object describing the record that + * should be served. The properties of this object are: + * - collectionId {string} This record should only be served if a + * request is for this collection. + * - predicate {Function} If present, this record should only be served if the + * predicate returns true. The predicate will be called with + * {request: Request, response: Response, since: number, server: KintoServer}. + * - data {string} The record to serve. + * - conflict {boolean} If present and true, this record is added to + * "conflicts" and won't be served, but will cause a conflict on + * the next push. + */ + addRecord(properties) { + if (!properties.conflict) { + this.records.push(properties); + } else { + this.conflicts.push(properties); + } + + this.installCollection(properties.collectionId); + } + + /** + * Tell the server to set up a route for this collection. + * + * This will automatically be called for any collection to which you `addRecord`. + * + * @param {string} collectionId the collection whose route we + * should set up. + */ + installCollection(collectionId) { + if (this.collections.has(collectionId)) { + return; + } + this.collections.add(collectionId); + const remoteCollectionPath = + "/v1" + collectionPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler( + remoteCollectionPath, + this.handleGetCollection.bind(this, collectionId) + ); + const remoteRecordsPath = + "/v1" + collectionRecordsPath(encodeURIComponent(collectionId)); + this.httpServer.registerPathHandler( + remoteRecordsPath, + this.handleGetRecords.bind(this, collectionId) + ); + } + + handleGetCollection(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.write( + JSON.stringify({ + data: { + id: collectionId, + }, + }) + ); + } + + handleGetRecords(collectionId, request, response) { + if (this.checkAuth(request, response)) { + return; + } + + if (request.method != "GET") { + do_throw(`only GET is supported on ${request.path}`); + } + + let sinceMatch = request.queryString.match(/(^|&)_since=(\d+)/); + let since = sinceMatch && parseInt(sinceMatch[2], 10); + + response.setStatusLine(null, 200, "OK"); + response.setHeader("Content-Type", "application/json; charset=UTF-8"); + response.setHeader("Date", new Date().toUTCString()); + response.setHeader("ETag", this.etag.toString()); + + const records = this.records + .filter(properties => { + if (properties.collectionId != collectionId) { + return false; + } + + if (properties.predicate) { + const predAllowed = properties.predicate({ + request: request, + response: response, + since: since, + server: this, + }); + if (!predAllowed) { + return false; + } + } + + return true; + }) + .map(properties => properties.data); + + const body = JSON.stringify({ + data: records, + }); + response.write(body); + } + + installDeleteBucket() { + this.httpServer.registerPrefixHandler( + "/v1/buckets/", + (request, response) => { + if (request.method != "DELETE") { + dump( + `got a non-delete action on bucket: ${request.method} ${request.path}\n` + ); + return; + } + + const noPrefix = request.path.slice("/v1/buckets/".length); + const [bucket, afterBucket] = noPrefix.split("/", 1); + if (afterBucket && afterBucket != "") { + dump( + `got a delete for a non-bucket: ${request.method} ${request.path}\n` + ); + } + + this.deletedBuckets.push(bucket); + // Fake like this actually deletes the records. + this.records = []; + + response.write( + JSON.stringify({ + data: { + deleted: true, + last_modified: 1475161309026, + id: "b09f1618-d789-302d-696e-74ec53ee18a8", // FIXME + }, + }) + ); + } + ); + } + + // Utility function to install a keyring at the start of a test. + async installKeyRing(fxaService, keysData, salts, etag, properties) { + const keysRecord = { + id: "keys", + keys: keysData, + salts: salts, + last_modified: etag, + }; + this.etag = etag; + const transformer = new KeyRingEncryptionRemoteTransformer(fxaService); + return this.encryptAndAddRecord( + transformer, + Object.assign({}, properties, { + collectionId: "storage-sync-crypto", + data: keysRecord, + }) + ); + } + + encryptAndAddRecord(transformer, properties) { + return transformer.encode(properties.data).then(encrypted => { + this.addRecord(Object.assign({}, properties, { data: encrypted })); + }); + } + + stop() { + this.httpServer.stop(() => {}); + } +} + +/** + * Predicate that represents a record appearing at some time. + * Requests with "_since" before this time should see this record, + * unless the server itself isn't at this time yet (etag is before + * this time). + * + * Requests with _since after this time shouldn't see this record any + * more, since it hasn't changed after this time. + * + * @param {int} startTime the etag at which time this record should + * start being available (and thus, the predicate should start + * returning true) + * @returns {Function} + */ +function appearsAt(startTime) { + return function({ since, server }) { + return since < startTime && startTime < server.etag; + }; +} + +// Run a block of code with access to a KintoServer. +async function withServer(f) { + let server = new KintoServer(); + // Point the sync.storage client to use the test server we've just started. + Services.prefs.setCharPref( + "webextensions.storage.sync.serverURL", + `http://localhost:${server.port}/v1` + ); + try { + await f(server); + } finally { + server.stop(); + } +} + +// Run a block of code with access to both a sync context and a +// KintoServer. This is meant as a workaround for eslint's refusal to +// let me have 5 nested callbacks. +async function withContextAndServer(f) { + await withSyncContext(async function(context) { + await withServer(async function(server) { + await f(context, server); + }); + }); +} + +// Run a block of code with fxa mocked out to return a specific user. +// Calls the given function with an ExtensionStorageSync instance that +// was constructed using a mocked FxAccounts instance. +async function withSignedInUser(user, f) { + let fxaServiceMock = { + getSignedInUser() { + return Promise.resolve({ uid: user.uid }); + }, + getOAuthToken() { + return Promise.resolve("some-access-token"); + }, + checkAccountStatus() { + return Promise.resolve(true); + }, + removeCachedOAuthToken() { + return Promise.resolve(); + }, + keys: { + getKeyForScope(scope) { + return Promise.resolve({ ...user.scopedKeys[scope] }); + }, + kidAsHex(jwk) { + return new FxAccountsKeys({}).kidAsHex(jwk); + }, + }, + }; + + let telemetryMock = { + _calls: [], + _histograms: {}, + scalarSet(name, value) { + this._calls.push({ method: "scalarSet", name, value }); + }, + keyedScalarSet(name, key, value) { + this._calls.push({ method: "keyedScalarSet", name, key, value }); + }, + getKeyedHistogramById(name) { + let self = this; + return { + add(key, value) { + if (!self._histograms[name]) { + self._histograms[name] = []; + } + self._histograms[name].push(value); + }, + }; + }, + }; + let extensionStorageSync = new ExtensionStorageSync( + fxaServiceMock, + telemetryMock + ); + await f(extensionStorageSync, fxaServiceMock); +} + +// Some assertions that make it easier to write tests about what was +// posted and when. + +// Assert that a post in a batch was made with the correct access token. +// This should be true of all requests, so this is usually called from +// another assertion. +function assertAuthenticatedPost(post) { + equal(post.headers.Authorization, "Bearer some-access-token"); +} + +// Assert that this post was made with the correct request headers to +// create a new resource while protecting against someone else +// creating it at the same time (in other words, "If-None-Match: *"). +// Also calls assertAuthenticatedPost(post). +function assertPostedNewRecord(post) { + assertAuthenticatedPost(post); + equal(post.headers["If-None-Match"], "*"); +} + +// Assert that this post was made with the correct request headers to +// update an existing resource while protecting against concurrent +// modification (in other words, `If-Match: "${etag}"`). +// Also calls assertAuthenticatedPost(post). +function assertPostedUpdatedRecord(post, since) { + assertAuthenticatedPost(post); + equal(post.headers["If-Match"], `"${since}"`); +} + +// Assert that this post was an encrypted keyring, and produce the +// decrypted body. Sanity check the body while we're here. +const assertPostedEncryptedKeys = async function(fxaService, post) { + equal(post.path, collectionRecordsPath("storage-sync-crypto") + "/keys"); + + let body = await new KeyRingEncryptionRemoteTransformer(fxaService).decode( + post.body.data + ); + ok(body.keys, `keys object should be present in decoded body`); + ok(body.keys.default, `keys object should have a default key`); + ok(body.salts, `salts object should be present in decoded body`); + return body; +}; + +// assertEqual, but for keyring[extensionId] == key. +function assertKeyRingKey(keyRing, extensionId, expectedKey, message) { + if (!message) { + message = `expected keyring's key for ${extensionId} to match ${expectedKey.keyPairB64}`; + } + ok( + keyRing.hasKeysFor([extensionId]), + `expected keyring to have a key for ${extensionId}\n` + ); + deepEqual( + keyRing.keyForCollection(extensionId).keyPairB64, + expectedKey.keyPairB64, + message + ); +} + +// Assert that this post was posted for a given extension. +const assertExtensionRecord = async function(fxaService, post, extension, key) { + const extensionId = extension.id; + const cryptoCollection = new CryptoCollection(fxaService); + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt(keyToId(key), extensionId)); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + const transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "decrypted data should be posted to path corresponding to its key" + ); + let decoded = await transformer.decode(post.body.data); + equal( + decoded.key, + key, + "decrypted data should have a key attribute corresponding to the extension data key" + ); + return decoded; +}; + +// Tests using this ID will share keys in local storage, so be careful. +const defaultExtensionId = "{13bdde76-4dc7-11e6-9bdc-54ee758d6342}"; +const defaultExtension = { id: defaultExtensionId }; + +const loggedInUser = { + uid: "0123456789abcdef0123456789abcdef", + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAA", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMQ", + kty: "oct", + }, + }, + oauthTokens: { + "sync:addon_storage": { + token: "some-access-token", + }, + }, +}; + +function uuid() { + const uuidgen = Cc["@mozilla.org/uuid-generator;1"].getService( + Ci.nsIUUIDGenerator + ); + return uuidgen.generateUUID().toString(); +} + +add_task(async function test_setup() { + await promiseStartupManager(); +}); + +add_task(async function test_single_initialization() { + // Grab access to this via the backstage pass to check if we're calling openConnection too often. + const { FirefoxAdapter } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSyncKinto.jsm", + null + ); + const origOpenConnection = FirefoxAdapter.openConnection; + let callCount = 0; + FirefoxAdapter.openConnection = function(...args) { + ++callCount; + return origOpenConnection.apply(this, args); + }; + function background() { + let promises = ["foo", "bar", "baz", "quux"].map(key => + browser.storage.sync.get(key) + ); + Promise.all(promises).then(() => + browser.test.notifyPass("initialize once") + ); + } + try { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["storage"], + }, + background: `(${background})()`, + }); + + await extension.startup(); + await extension.awaitFinish("initialize once"); + await extension.unload(); + equal( + callCount, + 1, + "Initialized FirefoxAdapter connection and Kinto exactly once" + ); + } finally { + FirefoxAdapter.openConnection = origOpenConnection; + } +}); + +add_task(async function test_key_to_id() { + equal(keyToId("foo"), "key-foo"); + equal(keyToId("my-new-key"), "key-my_2D_new_2D_key"); + equal(keyToId(""), "key-"); + equal(keyToId("™"), "key-_2122_"); + equal(keyToId("\b"), "key-_8_"); + equal(keyToId("abc\ndef"), "key-abc_A_def"); + equal(keyToId("Kinto's fancy_string"), "key-Kinto_27_s_20_fancy_5F_string"); + + const KEYS = ["foo", "my-new-key", "", "Kinto's fancy_string", "™", "\b"]; + for (let key of KEYS) { + equal(idToKey(keyToId(key)), key); + } + + equal(idToKey("hi"), null); + equal(idToKey("-key-hi"), null); + equal(idToKey("key--abcd"), null); + equal(idToKey("key-%"), null); + equal(idToKey("key-_HI"), null); + equal(idToKey("key-_HI_"), null); + equal(idToKey("key-"), ""); + equal(idToKey("key-1"), "1"); + equal(idToKey("key-_2D_"), "-"); +}); + +add_task(async function test_extension_id_to_collection_id() { + const extensionId = "{9419cce6-5435-11e6-84bf-54ee758d6342}"; + // FIXME: this doesn't actually require the signed in user, but the + // extensionIdToCollectionId method exists on CryptoCollection, + // which needs an fxaService to be instantiated. + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + // Fake a static keyring since the server doesn't exist. + const salt = "Scgx8RJ8Y0rxMGFYArUiKeawlW+0zJyFmtTDvro9qPo="; + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._setSalt(extensionId, salt); + + equal( + await cryptoCollection.extensionIdToCollectionId(extensionId), + "ext-0_QHA1P93_yJoj7ONisrR0lW6uN4PZ3Ii-rT-QOjtvo" + ); + }); +}); + +add_task(async function ensureCanSync_clearAll() { + // A test extension that will not have any active context around + // but it is returned from a call to AddonManager.getExtensionsByType. + const extensionId = "test-wipe-on-enabled-and-synced@mochi.test"; + const testExtension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { gecko: { id: extensionId } }, + }, + }); + + await testExtension.startup(); + + // Retrieve the Extension class instance from the test extension. + const { extension } = testExtension; + + // Another test extension that will have an active extension context. + const extensionId2 = "test-wipe-on-active-context@mochi.test"; + const extension2 = { id: extensionId2 }; + + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + async function assertSetAndGetData(extension, data) { + await extensionStorageSync.set(extension, data, context); + let storedData = await extensionStorageSync.get( + extension, + Object.keys(data), + context + ); + const extId = extensionId; + deepEqual(storedData, data, `${extId} should get back the data we set`); + } + + async function assertDataCleared(extension, keys) { + const storedData = await extensionStorageSync.get( + extension, + keys, + context + ); + deepEqual(storedData, {}, `${extension.id} should have lost the data`); + } + + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionId2, + ]); + ok( + newKeys.hasKeysFor([extensionId]), + `key isn't present for ${extensionId}` + ); + ok( + newKeys.hasKeysFor([extensionId2]), + `key isn't present for ${extensionId2}` + ); + + let posts = server.getPosts(); + equal(posts.length, 1); + assertPostedNewRecord(posts[0]); + + await assertSetAndGetData(extension, { "my-key": 1 }); + await assertSetAndGetData(extension2, { "my-key": 2 }); + + // Call cleanup for the first extension, to double check it has + // been wiped out even without an active extension context. + cleanUpForContext(extension, context); + + // clear everything. + await extensionStorageSync.clearAll(); + + // Assert that the data is gone for both the extensions. + await assertDataCleared(extension, ["my-key"]); + await assertDataCleared(extension2, ["my-key"]); + + // should have been no posts caused by the clear. + posts = server.getPosts(); + equal(posts.length, 1); + }); + }); + + await testExtension.unload(); +}); + +add_task(async function ensureCanSync_posts_new_keys() { + const extensionId = uuid(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + let newKeys = await extensionStorageSync.ensureCanSync([extensionId]); + ok( + newKeys.hasKeysFor([extensionId]), + `key isn't present for ${extensionId}` + ); + + let posts = server.getPosts(); + equal(posts.length, 1); + const post = posts[0]; + assertPostedNewRecord(post); + const body = await assertPostedEncryptedKeys(fxaService, post); + const oldSalt = body.salts[extensionId]; + ok( + body.keys.collections[extensionId], + `keys object should have a key for ${extensionId}` + ); + ok(oldSalt, `salts object should have a salt for ${extensionId}`); + + // Try adding another key to make sure that the first post was + // OK, even on a new profile. + await extensionStorageSync.cryptoCollection._clear(); + server.clearPosts(); + // Restore the first posted keyring, but add a last_modified date + const firstPostedKeyring = Object.assign({}, post.body.data, { + last_modified: server.etag, + }); + server.addRecord({ + data: firstPostedKeyring, + collectionId: "storage-sync-crypto", + predicate: appearsAt(250), + }); + const extensionId2 = uuid(); + newKeys = await extensionStorageSync.ensureCanSync([extensionId2]); + ok( + newKeys.hasKeysFor([extensionId]), + `didn't forget key for ${extensionId}` + ); + ok( + newKeys.hasKeysFor([extensionId2]), + `new key generated for ${extensionId2}` + ); + + posts = server.getPosts(); + equal(posts.length, 1); + const newPost = posts[posts.length - 1]; + const newBody = await assertPostedEncryptedKeys(fxaService, newPost); + ok( + newBody.keys.collections[extensionId], + `keys object should have a key for ${extensionId}` + ); + ok( + newBody.keys.collections[extensionId2], + `keys object should have a key for ${extensionId2}` + ); + ok( + newBody.salts[extensionId], + `salts object should have a key for ${extensionId}` + ); + ok( + newBody.salts[extensionId2], + `salts object should have a key for ${extensionId2}` + ); + equal( + oldSalt, + newBody.salts[extensionId], + `old salt should be preserved in post` + ); + }); + }); +}); + +add_task(async function ensureCanSync_pulls_key() { + // ensureCanSync is implemented by adding a key to our local record + // and doing a sync. This means that if the same key exists + // remotely, we get a "conflict". Ensure that we handle this + // correctly -- we keep the server key (since presumably it's + // already been used to encrypt records) and we don't wipe out other + // collections' keys. + const extensionId = uuid(); + const extensionId2 = uuid(); + const extensionOnlyKey = uuid(); + const extensionOnlySalt = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + await DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + await RANDOM_KEY.generateRandom(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + // FIXME: generating a random salt probably shouldn't require a CryptoCollection? + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + await extensionStorageSync.cryptoCollection._clear(); + const keysData = { + default: DEFAULT_KEY.keyPairB64, + collections: { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + await server.installKeyRing(fxaService, keysData, saltData, 950, { + predicate: appearsAt(900), + }); + + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + assertKeyRingKey(collectionKeys, extensionId, RANDOM_KEY); + + let posts = server.getPosts(); + equal( + posts.length, + 0, + "ensureCanSync shouldn't push when the server keyring has the right key" + ); + + // Another client generates a key for extensionId2 + const newKey = new BulkKeyBundle(extensionId2); + await newKey.generateRandom(); + keysData.collections[extensionId2] = newKey.keyPairB64; + saltData[extensionId2] = cryptoCollection.getNewSalt(); + await server.installKeyRing(fxaService, keysData, saltData, 1050, { + predicate: appearsAt(1000), + }); + + let newCollectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionId2, + ]); + assertKeyRingKey(newCollectionKeys, extensionId2, newKey); + assertKeyRingKey( + newCollectionKeys, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + + posts = server.getPosts(); + equal(posts.length, 0, "ensureCanSync shouldn't push when updating keys"); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const onlyKey = new BulkKeyBundle(extensionOnlyKey); + await onlyKey.generateRandom(); + keysData.collections[extensionOnlyKey] = onlyKey.keyPairB64; + await server.installKeyRing(fxaService, keysData, saltData, 1150, { + predicate: appearsAt(1100), + }); + + let withNewKey = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionOnlyKey, + ]); + dump(`got ${JSON.stringify(withNewKey.asWBO().cleartext)}\n`); + assertKeyRingKey(withNewKey, extensionOnlyKey, onlyKey); + assertKeyRingKey( + withNewKey, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + + posts = server.getPosts(); + equal( + posts.length, + 1, + "ensureCanSync should push when generating a new salt" + ); + const withNewKeyRecord = await assertPostedEncryptedKeys( + fxaService, + posts[0] + ); + // We don't a priori know what the new salt is + dump(`${JSON.stringify(withNewKeyRecord)}\n`); + ok( + withNewKeyRecord.salts[extensionOnlyKey], + `ensureCanSync should generate a salt for an extension that only had a key` + ); + + // Another client generates a key, but not a salt, for extensionOnlyKey + const newSalt = cryptoCollection.getNewSalt(); + saltData[extensionOnlySalt] = newSalt; + await server.installKeyRing(fxaService, keysData, saltData, 1250, { + predicate: appearsAt(1200), + }); + + let withOnlySaltKey = await extensionStorageSync.ensureCanSync([ + extensionId, + extensionOnlySalt, + ]); + assertKeyRingKey( + withOnlySaltKey, + extensionId, + RANDOM_KEY, + `ensureCanSync shouldn't lose the old key for ${extensionId}` + ); + // We don't a priori know what the new key is + ok( + withOnlySaltKey.hasKeysFor([extensionOnlySalt]), + `ensureCanSync generated a key for an extension that only had a salt` + ); + + posts = server.getPosts(); + equal( + posts.length, + 2, + "ensureCanSync should push when generating a new key" + ); + const withNewSaltRecord = await assertPostedEncryptedKeys( + fxaService, + posts[1] + ); + equal( + withNewSaltRecord.salts[extensionOnlySalt], + newSalt, + "ensureCanSync should keep the existing salt when generating only a key" + ); + }); + }); +}); + +add_task(async function ensureCanSync_handles_conflicts() { + // Syncing is done through a pull followed by a push of any merged + // changes. Accordingly, the only way to have a "true" conflict -- + // i.e. with the server rejecting a change -- is if + // someone pushes changes between our pull and our push. Ensure that + // if this happens, we still behave sensibly (keep the remote key). + const extensionId = uuid(); + const DEFAULT_KEY = new BulkKeyBundle("[default]"); + await DEFAULT_KEY.generateRandom(); + const RANDOM_KEY = new BulkKeyBundle(extensionId); + await RANDOM_KEY.generateRandom(); + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + // FIXME: generating salts probably shouldn't rely on a CryptoCollection + const cryptoCollection = new CryptoCollection(fxaService); + const RANDOM_SALT = cryptoCollection.getNewSalt(); + const keysData = { + default: DEFAULT_KEY.keyPairB64, + collections: { + [extensionId]: RANDOM_KEY.keyPairB64, + }, + }; + const saltData = { + [extensionId]: RANDOM_SALT, + }; + await server.installKeyRing(fxaService, keysData, saltData, 765, { + conflict: true, + }); + + await extensionStorageSync.cryptoCollection._clear(); + + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + assertKeyRingKey( + collectionKeys, + extensionId, + RANDOM_KEY, + `syncing keyring should keep the server key for ${extensionId}` + ); + + let posts = server.getPosts(); + equal( + posts.length, + 1, + "syncing keyring should have tried to post a keyring" + ); + const failedPost = posts[0]; + assertPostedNewRecord(failedPost); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + // This key will be the one the client generated locally, so + // we don't know what its value will be + ok( + body.keys.collections[extensionId], + `decrypted failed post should have a key for ${extensionId}` + ); + notEqual( + body.keys.collections[extensionId], + RANDOM_KEY.keyPairB64, + `decrypted failed post should have a randomly-generated key for ${extensionId}` + ); + }); + }); +}); + +add_task(async function ensureCanSync_handles_deleted_conflicts() { + // A keyring can be deleted, and this changes the format of the 412 + // Conflict response from the Kinto server. Make sure we handle it correctly. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function(context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + server.etag = 700; + await extensionStorageSync.cryptoCollection._clear(); + + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // This is the response that the Kinto server return when the + // keyring has been deleted. + server.addRecord({ + collectionId: "storage-sync-crypto", + conflict: true, + transient: true, + data: null, + etag: 765, + }); + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([ + extensionId2, + ]); + + assertKeyRingKey( + collectionKeys2, + extensionId, + extensionKey, + `syncing keyring should keep our local key for ${extensionId}` + ); + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "syncing keyring should have tried to post a keyring twice" + ); + // The first post got a conflict. + const failedPost = posts[0]; + assertPostedUpdatedRecord(failedPost, 700); + let body = await assertPostedEncryptedKeys(fxaService, failedPost); + + deepEqual( + body.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted failed post should have the key for ${extensionId}` + ); + + // The second post was after the wipe, and succeeded. + const afterWipePost = posts[1]; + assertPostedNewRecord(afterWipePost); + let afterWipeBody = await assertPostedEncryptedKeys( + fxaService, + afterWipePost + ); + + deepEqual( + afterWipeBody.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}` + ); + }); + }); +}); + +add_task(async function ensureCanSync_handles_flushes() { + // See Bug 1359879 and Bug 1350088. One of the ways that 1359879 presents is + // as 1350088. This seems to be the symptom that results when the user had + // two devices, one of which was not syncing at the time the keyring was + // lost. Ensure we can recover for these users as well. + const extensionId = uuid(); + const extensionId2 = uuid(); + await withContextAndServer(async function(context, server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + server.etag = 700; + // Generate keys that we can check for later. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const extensionKey = collectionKeys.keyForCollection(extensionId); + server.clearPosts(); + + // last_modified is new, but there is no data. + server.etag = 800; + + // Try to add a new extension to trigger a sync of the keyring. + let collectionKeys2 = await extensionStorageSync.ensureCanSync([ + extensionId2, + ]); + + assertKeyRingKey( + collectionKeys2, + extensionId, + extensionKey, + `syncing keyring should keep our local key for ${extensionId}` + ); + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal( + posts.length, + 1, + "syncing keyring should have tried to post a keyring once" + ); + + const post = posts[0]; + assertPostedNewRecord(post); + let postBody = await assertPostedEncryptedKeys(fxaService, post); + + deepEqual( + postBody.keys.collections[extensionId], + extensionKey.keyPairB64, + `decrypted new post should have preserved the key for ${extensionId}` + ); + }); + }); +}); + +add_task(async function checkSyncKeyRing_reuploads_keys() { + // Verify that when keys are present, they are reuploaded with the + // new kbHash when we call touchKeys(). + const extensionId = uuid(); + let extensionKey, extensionSalt; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + server.installCollection("storage-sync-crypto"); + server.etag = 765; + + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should return a keyring that has a key for ${extensionId}` + ); + extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + equal( + server.getPosts().length, + 1, + "generating a key that doesn't exist on the server should post it" + ); + const body = await assertPostedEncryptedKeys( + fxaService, + server.getPosts()[0] + ); + extensionSalt = body.salts[extensionId]; + }); + + // The user changes their password. This is their new kbHash, with + // the last character changed. + const newUser = Object.assign({}, loggedInUser, { + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, + }); + let postedKeys; + await withSignedInUser(newUser, async function( + extensionStorageSync, + fxaService + ) { + await extensionStorageSync.checkSyncKeyRing(); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "when kBHash changes, checkSyncKeyRing should post the keyring reencrypted with the new kBHash" + ); + postedKeys = posts[1]; + assertPostedUpdatedRecord(postedKeys, 765); + + let body = await assertPostedEncryptedKeys(fxaService, postedKeys); + deepEqual( + body.keys.collections[extensionId], + extensionKey, + `the posted keyring should have the same key for ${extensionId} as the old one` + ); + deepEqual( + body.salts[extensionId], + extensionSalt, + `the posted keyring should have the same salt for ${extensionId} as the old one` + ); + }); + + // Verify that with the old kBHash, we can't decrypt the record. + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + let error; + try { + await new KeyRingEncryptionRemoteTransformer(fxaService).decode( + postedKeys.body.data + ); + } catch (e) { + error = e; + } + ok(error, "decrypting the keyring with the old kBHash should fail"); + ok( + Utils.isHMACMismatch(error) || + KeyRingEncryptionRemoteTransformer.isOutdatedKB(error), + "decrypting the keyring with the old kBHash should throw an HMAC mismatch" + ); + }); + }); +}); + +add_task(async function checkSyncKeyRing_overwrites_on_conflict() { + // If there is already a record on the server that was encrypted + // with a different kbHash, we wipe the server, clear sync state, and + // overwrite it with our keys. + const extensionId = uuid(); + let extensionKey; + await withSyncContext(async function(context) { + await withServer(async function(server) { + // The old device has this kbHash, which is very similar to the + // current kbHash but with the last character changed. + const oldUser = Object.assign({}, loggedInUser, { + scopedKeys: { + "sync:addon_storage": { + kid: "1234567890123-I1DLqPztWi-647HxgLr4YPePZUK-975wn9qWzT49yAE", + k: + "Y_kFdXfAS7u58MP9hbXUAytg4T7cH43TCb9DBdZvLMMS3eFs5GAhpJb3E5UNCmxWbOGBUhpEcm576Xz1d7MbMA", + kty: "oct", + }, + }, + }); + server.installDeleteBucket(); + await withSignedInUser(oldUser, async function( + extensionStorageSync, + fxaService + ) { + await server.installKeyRing(fxaService, {}, {}, 765); + }); + + // Now we have this new user with a different kbHash. + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to generate some keys. + // This will try to sync, notice that the record is + // undecryptable, and clear the server. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring with a key for ${extensionId}` + ); + extensionKey = collectionKeys.keyForCollection(extensionId).keyPairB64; + + deepEqual( + server.getDeletedBuckets(), + ["default"], + "Kinto server should have been wiped when keyring was thrown away" + ); + + let posts = server.getPosts(); + equal(posts.length, 1, "new keyring should have been uploaded"); + const postedKeys = posts[0]; + // The POST was to an empty server, so etag shouldn't be respected + equal( + postedKeys.headers.Authorization, + "Bearer some-access-token", + "keyring upload should be authorized" + ); + equal( + postedKeys.headers["If-None-Match"], + "*", + "keyring upload should be to empty Kinto server" + ); + equal( + postedKeys.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring upload should be to keyring path" + ); + + let body = await new KeyRingEncryptionRemoteTransformer( + fxaService + ).decode(postedKeys.body.data); + ok(body.uuid, "new keyring should have a UUID"); + equal(typeof body.uuid, "string", "keyring UUIDs should be strings"); + notEqual( + body.uuid, + "abcd", + "new keyring should not have the same UUID as previous keyring" + ); + ok(body.keys, "new keyring should have a keys attribute"); + ok(body.keys.default, "new keyring should have a default key"); + // We should keep the extension key that was in our uploaded version. + deepEqual( + extensionKey, + body.keys.collections[extensionId], + "ensureCanSync should have returned keyring with the same key that was uploaded" + ); + + // This should be a no-op; the keys were uploaded as part of ensurekeysfor + await extensionStorageSync.checkSyncKeyRing(); + equal( + server.getPosts().length, + 1, + "checkSyncKeyRing should not need to post keys after they were reuploaded" + ); + }); + }); + }); +}); + +add_task(async function checkSyncKeyRing_flushes_on_uuid_change() { + // If we can decrypt the record, but the UUID has changed, that + // means another client has wiped the server and reuploaded a + // keyring, so reset sync state and reupload everything. + const extensionId = uuid(); + const extension = { id: extensionId }; + await withSyncContext(async function(context) { + await withServer(async function(server) { + server.installCollection("storage-sync-crypto"); + server.installDeleteBucket(); + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + const transformer = new KeyRingEncryptionRemoteTransformer(fxaService); + await extensionStorageSync.cryptoCollection._clear(); + + // Do an `ensureCanSync` to get access to keys and salt. + let collectionKeys = await extensionStorageSync.ensureCanSync([ + extensionId, + ]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + server.installCollection(collectionId); + + ok( + collectionKeys.hasKeysFor([extensionId]), + `ensureCanSync should always return a keyring that has a key for ${extensionId}` + ); + const extensionKey = collectionKeys.keyForCollection(extensionId) + .keyPairB64; + + // Set something to make sure that it gets re-uploaded when + // uuid changes. + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + await extensionStorageSync.syncAll(); + + let posts = server.getPosts(); + equal( + posts.length, + 2, + "should have posted a new keyring and an extension datum" + ); + const postedKeys = posts[0]; + equal( + postedKeys.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "should have posted keyring to /keys" + ); + + let body = await transformer.decode(postedKeys.body.data); + ok(body.uuid, "keyring should have a UUID"); + ok(body.keys, "keyring should have a keys attribute"); + ok(body.keys.default, "keyring should have a default key"); + ok( + body.salts[extensionId], + `keyring should have a salt for ${extensionId}` + ); + const extensionSalt = body.salts[extensionId]; + deepEqual( + extensionKey, + body.keys.collections[extensionId], + "new keyring should have the same key that we uploaded" + ); + + // Another client comes along and replaces the UUID. + // In real life, this would mean changing the keys too, but + // this test verifies that just changing the UUID is enough. + const newKeyRingData = Object.assign({}, body, { + uuid: "abcd", + // Technically, last_modified should be served outside the + // object, but the transformer will pass it through in + // either direction, so this is OK. + last_modified: 765, + }); + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId: "storage-sync-crypto", + data: newKeyRingData, + predicate: appearsAt(800), + }); + + // Fake adding another extension just so that the keyring will + // really get synced. + const newExtension = uuid(); + const newKeyRing = await extensionStorageSync.ensureCanSync([ + newExtension, + ]); + + // This should have detected the UUID change and flushed everything. + // The keyring should, however, be the same, since we just + // changed the UUID of the previously POSTed one. + deepEqual( + newKeyRing.keyForCollection(extensionId).keyPairB64, + extensionKey, + "ensureCanSync should have pulled down a new keyring with the same keys" + ); + + // Syncing should reupload the data for the extension. + await extensionStorageSync.syncAll(); + posts = server.getPosts(); + equal( + posts.length, + 4, + "should have posted keyring for new extension and reuploaded extension data" + ); + + const finalKeyRingPost = posts[2]; + const reuploadedPost = posts[3]; + + equal( + finalKeyRingPost.path, + collectionRecordsPath("storage-sync-crypto") + "/keys", + "keyring for new extension should have been posted to /keys" + ); + let finalKeyRing = await transformer.decode(finalKeyRingPost.body.data); + equal( + finalKeyRing.uuid, + "abcd", + "newly uploaded keyring should preserve UUID from replacement keyring" + ); + deepEqual( + finalKeyRing.salts[extensionId], + extensionSalt, + "newly uploaded keyring should preserve salts from existing salts" + ); + + // Confirm that the data got reuploaded + let reuploadedData = await assertExtensionRecord( + fxaService, + reuploadedPost, + extension, + "my-key" + ); + equal( + reuploadedData.data, + 5, + "extension data should have a data attribute corresponding to the extension data value" + ); + }); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_changes() { + const extensionId = defaultExtensionId; + const extension = defaultExtension; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.syncAll(); + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue, + 6, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + + equal(calls.length, 1, "syncing calls on-changed listener"); + deepEqual(calls[0][0], { "remote-key": { newValue: 6 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue2, + 7, + "ExtensionStorageSync.get() returns value updated from sync" + ); + + equal(calls.length, 1, "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], { "remote-key": { oldValue: 6, newValue: 7 } }); + }); + }); +}); + +// Tests that an enabled extension which have been synced before it is going +// to be synced on ExtensionStorageSync.syncAll even if there is no active +// context that is currently using the API. +add_task(async function test_storage_sync_on_no_active_context() { + const extensionId = "sync@mochi.test"; + const extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["storage"], + applications: { gecko: { id: extensionId } }, + }, + files: { + "ext-page.html": `<!DOCTYPE html> + <html> + <head> + <script src="ext-page.js"></script> + </head> + </html> + `, + "ext-page.js": function() { + const { browser } = this; + browser.test.onMessage.addListener(async msg => { + if (msg === "get-sync-data") { + browser.test.sendMessage( + "get-sync-data:done", + await browser.storage.sync.get(["remote-key"]) + ); + } + }); + }, + }, + }); + + await extension.startup(); + + await withServer(async server => { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + + server.etag = 1000; + await extensionStorageSync.syncAll(); + }); + }); + + const extPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/ext-page.html`, + { extension } + ); + + await extension.sendMessage("get-sync-data"); + const res = await extension.awaitMessage("get-sync-data:done"); + Assert.deepEqual(res, { "remote-key": 6 }, "Got the expected sync data"); + + await extPage.close(); + + await extension.unload(); +}); + +add_task(async function test_storage_sync_pushes_changes() { + // FIXME: This test relies on the fact that previous tests pushed + // keys and salts for the default extension ID + const extension = defaultExtension; + const extensionId = defaultExtensionId; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + + // install this AFTER we set the key to 5... + let calls = []; + extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + const localValue = ( + await extensionStorageSync.get(extension, "my-key", context) + )["my-key"]; + equal( + localValue, + 5, + "pushing an ExtensionStorageSync value shouldn't change local value" + ); + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt( + "key-my_2D_key", + extensionId + )); + + let posts = server.getPosts(); + // FIXME: Keys were pushed in a previous test + equal( + posts.length, + 1, + "pushing a value should cause a post to the server" + ); + const post = posts[0]; + assertPostedNewRecord(post); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a value should have a path corresponding to its id" + ); + + const encrypted = post.body.data; + ok( + encrypted.ciphertext, + "pushing a value should post an encrypted record" + ); + ok(!encrypted.data, "pushing a value should not have any plaintext data"); + equal( + encrypted.id, + hashedId, + "pushing a value should use a kinto-friendly record ID" + ); + + const record = await assertExtensionRecord( + fxaService, + post, + extension, + "my-key" + ); + equal( + record.data, + 5, + "when decrypted, a pushed value should have a data field corresponding to its storage.sync value" + ); + equal( + record.id, + "key-my_2D_key", + "when decrypted, a pushed value should have an id field corresponding to its record ID" + ); + + equal( + calls.length, + 0, + "pushing a value shouldn't call the on-changed listener" + ); + + await extensionStorageSync.set(extension, { "my-key": 6 }, context); + await extensionStorageSync.syncAll(); + + // Doesn't push keys because keys were pushed by a previous test. + posts = server.getPosts(); + equal(posts.length, 2, "updating a value should trigger another push"); + const updatePost = posts[1]; + assertPostedUpdatedRecord(updatePost, 1000); + equal( + updatePost.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing an updated value should go to the same path" + ); + + const updateEncrypted = updatePost.body.data; + ok( + updateEncrypted.ciphertext, + "pushing an updated value should still be encrypted" + ); + ok( + !updateEncrypted.data, + "pushing an updated value should not have any plaintext visible" + ); + equal( + updateEncrypted.id, + hashedId, + "pushing an updated value should maintain the same ID" + ); + }); + }); +}); + +add_task(async function test_storage_sync_retries_failed_auth() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + // Put a remote record just to verify that eventually we succeeded + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + // This is a typical response from a production stack if your + // bearer token is bad. + server.rejectNextAuthWith( + '{"code": 401, "errno": 104, "error": "Unauthorized", "message": "Please authenticate yourself to use this endpoint"}' + ); + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 1, "an auth was failed"); + + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue, + 6, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + + // Try again with an emptier JSON body to make sure this still + // works with a less-cooperative server. + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + server.etag = 1000; + // Need to write a JSON response. + // kinto.js 9.0.2 doesn't throw unless there's json. + // See https://github.com/Kinto/kinto-http.js/issues/192. + server.rejectNextAuthWith("{}"); + + await extensionStorageSync.syncAll(); + + equal(server.failedAuths.length, 2, "an auth was failed"); + + const newRemoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + newRemoteValue, + 7, + "ExtensionStorageSync.get() returns value retrieved from sync" + ); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_conflicts() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.ensureCanSync([extensionId]); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + let transformer = new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 6, + }, + predicate: appearsAt(850), + }); + server.etag = 900; + + await extensionStorageSync.set(extension, { "remote-key": 8 }, context); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + const remoteValue = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal(remoteValue, 8, "locally set value overrides remote value"); + + equal(calls.length, 1, "conflicts manifest in on-changed listener"); + deepEqual(calls[0][0], { "remote-key": { newValue: 8 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + + // Updating the server causes us to pull down the new value + server.etag = 1000; + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-remote_2D_key", + key: "remote-key", + data: 7, + }, + predicate: appearsAt(950), + }); + + await extensionStorageSync.syncAll(); + const remoteValue2 = ( + await extensionStorageSync.get(extension, "remote-key", context) + )["remote-key"]; + equal( + remoteValue2, + 7, + "conflicts do not prevent retrieval of new values" + ); + + equal(calls.length, 1, "syncing calls on-changed listener on update"); + deepEqual(calls[0][0], { "remote-key": { oldValue: 8, newValue: 7 } }); + }); + }); +}); + +add_task(async function test_storage_sync_pulls_deletes() { + const extension = defaultExtension; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + defaultExtensionId + ); + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + await extensionStorageSync.syncAll(); + server.clearPosts(); + + let calls = []; + await extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + const transformer = new CollectionKeyEncryptionRemoteTransformer( + new CryptoCollection(fxaService), + await cryptoCollection.getKeyRing(), + extension.id + ); + await server.encryptAndAddRecord(transformer, { + collectionId, + data: { + id: "key-my_2D_key", + data: 6, + _status: "deleted", + }, + }); + + await extensionStorageSync.syncAll(); + const remoteValues = await extensionStorageSync.get( + extension, + "my-key", + context + ); + ok( + !remoteValues["my-key"], + "ExtensionStorageSync.get() shows value was deleted by sync" + ); + + equal( + server.getPosts().length, + 0, + "pulling the delete shouldn't cause posts" + ); + + equal(calls.length, 1, "syncing calls on-changed listener"); + deepEqual(calls[0][0], { "my-key": { oldValue: 5 } }); + calls = []; + + // Syncing again doesn't do anything + await extensionStorageSync.syncAll(); + + equal( + calls.length, + 0, + "syncing again shouldn't call on-changed listener" + ); + }); + }); +}); + +add_task(async function test_storage_sync_pushes_deletes() { + const extensionId = uuid(); + const extension = { id: extensionId }; + await withContextAndServer(async function(context, server) { + await withSignedInUser(loggedInUser, async function( + extensionStorageSync, + fxaService + ) { + const cryptoCollection = new CryptoCollection(fxaService); + await cryptoCollection._clear(); + await cryptoCollection._setSalt( + extensionId, + cryptoCollection.getNewSalt() + ); + const collectionId = await cryptoCollection.extensionIdToCollectionId( + extensionId + ); + + server.installCollection(collectionId); + server.installCollection("storage-sync-crypto"); + server.etag = 1000; + + await extensionStorageSync.set(extension, { "my-key": 5 }, context); + + let calls = []; + extensionStorageSync.addOnChangedListener( + extension, + function() { + calls.push(arguments); + }, + context + ); + + await extensionStorageSync.syncAll(); + let posts = server.getPosts(); + equal( + posts.length, + 2, + "pushing a non-deleted value should post keys and post the value to the server" + ); + + await extensionStorageSync.remove(extension, ["my-key"], context); + equal( + calls.length, + 1, + "deleting a value should call the on-changed listener" + ); + + await extensionStorageSync.syncAll(); + equal( + calls.length, + 1, + "pushing a deleted value shouldn't call the on-changed listener" + ); + + // Doesn't push keys because keys were pushed by a previous test. + const hashedId = + "id-" + + (await cryptoCollection.hashWithExtensionSalt( + "key-my_2D_key", + extensionId + )); + posts = server.getPosts(); + equal(posts.length, 3, "deleting a value should trigger another push"); + const post = posts[2]; + assertPostedUpdatedRecord(post, 1000); + equal( + post.path, + `${collectionRecordsPath(collectionId)}/${hashedId}`, + "pushing a deleted value should go to the same path" + ); + ok(post.method, "PUT"); + ok( + post.body.data.ciphertext, + "deleting a value should have an encrypted body" + ); + const decoded = await new CollectionKeyEncryptionRemoteTransformer( + cryptoCollection, + await cryptoCollection.getKeyRing(), + extensionId + ).decode(post.body.data); + equal(decoded._status, "deleted"); + // Ideally, we'd check that decoded.deleted is not true, because + // the encrypted record shouldn't have it, but the decoder will + // add it when it sees _status == deleted + }); + }); +}); + +// Some sync tests shared between implementations. +add_task(test_config_flag_needed); + +add_task(test_sync_reloading_extensions_works); + +add_task(function test_storage_sync() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_page_storage("sync") + ); +}); + +add_task(test_storage_sync_requires_real_id); + +add_task(function test_storage_sync_with_bytes_in_use() { + return runWithPrefs([[STORAGE_SYNC_PREF, true]], () => + test_background_storage_area_with_bytes_in_use("sync", false) + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js new file mode 100644 index 0000000000..8c4137b078 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_sync_kinto_crypto.js @@ -0,0 +1,122 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// This is a kinto-specific test... +Services.prefs.setBoolPref("webextensions.storage.sync.kinto", true); + +const { EncryptionRemoteTransformer } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageSyncKinto.jsm", + null +); +const { CryptoUtils } = ChromeUtils.import( + "resource://services-crypto/utils.js" +); +const { Utils } = ChromeUtils.import("resource://services-sync/util.js"); + +/** + * Like Assert.throws, but for generators. + * + * @param {string | Object | function} constraint + * What to use to check the exception. + * @param {function} f + * The function to call. + */ +async function throwsGen(constraint, f) { + let threw = false; + let exception; + try { + await f(); + } catch (e) { + threw = true; + exception = e; + } + + ok(threw, "did not throw an exception"); + + const debuggingMessage = `got ${exception}, expected ${constraint}`; + + if (typeof constraint === "function") { + ok(constraint(exception), debuggingMessage); + } else { + let message = exception; + if (typeof exception === "object") { + message = exception.message; + } + ok(constraint === message, debuggingMessage); + } +} + +/** + * An EncryptionRemoteTransformer that uses a fixed key bundle, + * suitable for testing. + */ +class StaticKeyEncryptionRemoteTransformer extends EncryptionRemoteTransformer { + constructor(keyBundle) { + super(); + this.keyBundle = keyBundle; + } + + getKeys() { + return Promise.resolve(this.keyBundle); + } +} +const BORING_KB = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; +let transformer; +add_task(async function setup() { + const STRETCHED_KEY = await CryptoUtils.hkdfLegacy( + BORING_KB, + undefined, + `testing storage.sync encryption`, + 2 * 32 + ); + const KEY_BUNDLE = { + sha256HMACHasher: Utils.makeHMACHasher( + Ci.nsICryptoHMAC.SHA256, + Utils.makeHMACKey(STRETCHED_KEY.slice(0, 32)) + ), + encryptionKeyB64: btoa(STRETCHED_KEY.slice(32, 64)), + }; + transformer = new StaticKeyEncryptionRemoteTransformer(KEY_BUNDLE); +}); + +add_task(async function test_encryption_transformer_roundtrip() { + const POSSIBLE_DATAS = [ + "string", + 2, // number + [1, 2, 3], // array + { key: "value" }, // object + ]; + + for (let data of POSSIBLE_DATAS) { + const record = { data, id: "key-some_2D_key", key: "some-key" }; + + deepEqual( + record, + await transformer.decode(await transformer.encode(record)) + ); + } +}); + +add_task(async function test_refuses_to_decrypt_tampered() { + const encryptedRecord = await transformer.encode({ + data: [1, 2, 3], + id: "key-some_2D_key", + key: "some-key", + }); + const tamperedHMAC = Object.assign({}, encryptedRecord, { + hmac: "0000000000000000000000000000000000000000000000000000000000000001", + }); + await throwsGen(Utils.isHMACMismatch, async function() { + await transformer.decode(tamperedHMAC); + }); + + const tamperedIV = Object.assign({}, encryptedRecord, { + IV: "aaaaaaaaaaaaaaaaaaaaaa==", + }); + await throwsGen(Utils.isHMACMismatch, async function() { + await transformer.decode(tamperedIV); + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js new file mode 100644 index 0000000000..7d7f70ee14 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_tab.js @@ -0,0 +1,245 @@ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageIDB.jsm" +); + +async function test_multiple_pages() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + function awaitMessage(expectedMsg, api = "test") { + return new Promise(resolve => { + browser[api].onMessage.addListener(function listener(msg) { + if (msg === expectedMsg) { + browser[api].onMessage.removeListener(listener); + resolve(); + } + }); + }); + } + + let tabReady = awaitMessage("tab-ready", "runtime"); + + try { + let storage = browser.storage.local; + + browser.test.sendMessage( + "load-page", + browser.runtime.getURL("tab.html") + ); + await awaitMessage("page-loaded"); + await tabReady; + + let result = await storage.get("key"); + browser.test.assertEq(undefined, result.key, "Key should be undefined"); + + await browser.runtime.sendMessage("tab-set-key"); + + result = await storage.get("key"); + browser.test.assertEq( + JSON.stringify({ foo: { bar: "baz" } }), + JSON.stringify(result.key), + "Key should be set to the value from the tab" + ); + + browser.test.sendMessage("remove-page"); + await awaitMessage("page-removed"); + + result = await storage.get("key"); + browser.test.assertEq( + JSON.stringify({ foo: { bar: "baz" } }), + JSON.stringify(result.key), + "Key should still be set to the value from the tab" + ); + + browser.test.notifyPass("storage-multiple"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("storage-multiple"); + } + }, + + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="tab.js"></script> + </head> + </html>`, + + "tab.js"() { + browser.test.log("tab"); + browser.runtime.onMessage.addListener(msg => { + if (msg == "tab-set-key") { + return browser.storage.local.set({ key: { foo: { bar: "baz" } } }); + } + }); + + browser.runtime.sendMessage("tab-ready"); + }, + }, + + manifest: { + permissions: ["storage"], + }, + }); + + let contentPage; + extension.onMessage("load-page", async url => { + contentPage = await ExtensionTestUtils.loadContentPage(url, { extension }); + extension.sendMessage("page-loaded"); + }); + extension.onMessage("remove-page", async url => { + await contentPage.close(); + extension.sendMessage("page-removed"); + }); + + await extension.startup(); + await extension.awaitFinish("storage-multiple"); + await extension.unload(); +} + +add_task(async function test_storage_local_file_backend_from_tab() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_multiple_pages + ); +}); + +add_task(async function test_storage_local_idb_backend_from_tab() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], + test_multiple_pages + ); +}); + +async function test_storage_local_call_from_destroying_context() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let numberOfChanges = 0; + browser.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== "local") { + browser.test.fail( + `Received unexpected storage changes for "${areaName}"` + ); + } + + numberOfChanges++; + }); + + browser.test.onMessage.addListener(async ({ msg, values }) => { + switch (msg) { + case "storage-set": { + await browser.storage.local.set(values); + browser.test.sendMessage("storage-set:done"); + break; + } + case "storage-get": { + const res = await browser.storage.local.get(); + browser.test.sendMessage("storage-get:done", res); + break; + } + case "storage-changes": { + browser.test.sendMessage("storage-changes-count", numberOfChanges); + break; + } + default: + browser.test.fail(`Received unexpected message: ${msg}`); + } + }); + + browser.test.sendMessage( + "ext-page-url", + browser.runtime.getURL("tab.html") + ); + }, + files: { + "tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="tab.js"></script> + </head> + </html>`, + + "tab.js"() { + browser.test.log("Extension tab - calling storage.local API method"); + // Call the storage.local API from a tab that is going to be quickly closed. + browser.storage.local.set({ + "test-key-from-destroying-context": "testvalue2", + }); + // Navigate away from the extension page, so that the storage.local API call will be unable + // to send the call to the caller context (because it has been destroyed in the meantime). + window.location = "about:blank"; + }, + }, + manifest: { + permissions: ["storage"], + }, + }); + + await extension.startup(); + const url = await extension.awaitMessage("ext-page-url"); + + let contentPage = await ExtensionTestUtils.loadContentPage(url, { + extension, + }); + let expectedBackgroundPageData = { + "test-key-from-background-page": "test-value", + }; + let expectedTabData = { "test-key-from-destroying-context": "testvalue2" }; + + info( + "Call storage.local.set from the background page and wait it to be completed" + ); + extension.sendMessage({ + msg: "storage-set", + values: expectedBackgroundPageData, + }); + await extension.awaitMessage("storage-set:done"); + + info( + "Call storage.local.get from the background page and wait it to be completed" + ); + extension.sendMessage({ msg: "storage-get" }); + let res = await extension.awaitMessage("storage-get:done"); + + Assert.deepEqual( + res, + { + ...expectedBackgroundPageData, + ...expectedTabData, + }, + "Got the expected data set in the storage.local backend" + ); + + extension.sendMessage({ msg: "storage-changes" }); + equal( + await extension.awaitMessage("storage-changes-count"), + 2, + "Got the expected number of storage.onChanged event received" + ); + + contentPage.close(); + + await extension.unload(); +} + +add_task( + async function test_storage_local_file_backend_destroyed_context_promise() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_storage_local_call_from_destroying_context + ); + } +); + +add_task( + async function test_storage_local_idb_backend_destroyed_context_promise() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, true]], + test_storage_local_call_from_destroying_context + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js new file mode 100644 index 0000000000..f4a7574337 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_storage_telemetry.js @@ -0,0 +1,369 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { ExtensionStorageIDB } = ChromeUtils.import( + "resource://gre/modules/ExtensionStorageIDB.jsm" +); +const { getTrimmedString } = ChromeUtils.import( + "resource://gre/modules/ExtensionTelemetry.jsm" +); +const { TelemetryController } = ChromeUtils.import( + "resource://gre/modules/TelemetryController.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +const HISTOGRAM_JSON_IDS = [ + "WEBEXT_STORAGE_LOCAL_SET_MS", + "WEBEXT_STORAGE_LOCAL_GET_MS", +]; +const KEYED_HISTOGRAM_JSON_IDS = [ + "WEBEXT_STORAGE_LOCAL_SET_MS_BY_ADDONID", + "WEBEXT_STORAGE_LOCAL_GET_MS_BY_ADDONID", +]; + +const HISTOGRAM_IDB_IDS = [ + "WEBEXT_STORAGE_LOCAL_IDB_SET_MS", + "WEBEXT_STORAGE_LOCAL_IDB_GET_MS", +]; +const KEYED_HISTOGRAM_IDB_IDS = [ + "WEBEXT_STORAGE_LOCAL_IDB_SET_MS_BY_ADDONID", + "WEBEXT_STORAGE_LOCAL_IDB_GET_MS_BY_ADDONID", +]; + +const HISTOGRAM_IDS = [].concat(HISTOGRAM_JSON_IDS, HISTOGRAM_IDB_IDS); +const KEYED_HISTOGRAM_IDS = [].concat( + KEYED_HISTOGRAM_JSON_IDS, + KEYED_HISTOGRAM_IDB_IDS +); + +const EXTENSION_ID1 = "@test-extension1"; +const EXTENSION_ID2 = "@test-extension2"; + +async function test_telemetry_background() { + const expectedEmptyHistograms = ExtensionStorageIDB.isBackendEnabled + ? HISTOGRAM_JSON_IDS + : HISTOGRAM_IDB_IDS; + const expectedEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled + ? KEYED_HISTOGRAM_JSON_IDS + : KEYED_HISTOGRAM_IDB_IDS; + + const expectedNonEmptyHistograms = ExtensionStorageIDB.isBackendEnabled + ? HISTOGRAM_IDB_IDS + : HISTOGRAM_JSON_IDS; + const expectedNonEmptyKeyedHistograms = ExtensionStorageIDB.isBackendEnabled + ? KEYED_HISTOGRAM_IDB_IDS + : KEYED_HISTOGRAM_JSON_IDS; + + const server = createHttpServer(); + server.registerDirectory("/data/", do_get_file("data")); + + const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + + async function contentScript() { + await browser.storage.local.set({ a: "b" }); + await browser.storage.local.get("a"); + browser.test.sendMessage("contentDone"); + } + + let baseManifest = { + permissions: ["storage"], + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + }, + ], + }; + + let baseExtInfo = { + async background() { + await browser.storage.local.set({ a: "b" }); + await browser.storage.local.get("a"); + browser.test.sendMessage("backgroundDone"); + }, + files: { + "content_script.js": contentScript, + }, + }; + + let extension1 = ExtensionTestUtils.loadExtension({ + ...baseExtInfo, + manifest: { + ...baseManifest, + applications: { + gecko: { id: EXTENSION_ID1 }, + }, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + ...baseExtInfo, + manifest: { + ...baseManifest, + applications: { + gecko: { id: EXTENSION_ID2 }, + }, + }, + }); + + clearHistograms(); + + let process = IS_OOP ? "extension" : "parent"; + let snapshots = getSnapshots(process); + let keyedSnapshots = getKeyedSnapshots(process); + + for (let id of HISTOGRAM_IDS) { + ok(!(id in snapshots), `No data recorded for histogram: ${id}.`); + } + + for (let id of KEYED_HISTOGRAM_IDS) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id] || {}), + [], + `No data recorded for histogram: ${id}.` + ); + } + + await extension1.startup(); + await extension1.awaitMessage("backgroundDone"); + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, 1); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID1, 1); + } + + // Telemetry from extension1's background page should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + 1, + `Data recorded for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]), + [EXTENSION_ID1], + `Data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID1].values), + 1, + `Data recorded for histogram: ${id}.` + ); + } + + await extension2.startup(); + await extension2.awaitMessage("backgroundDone"); + + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, 2); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded(id, process, EXTENSION_ID2, 1); + } + + // Telemetry from extension2's background page should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + 2, + `Additional data recorded for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]).sort(), + [EXTENSION_ID1, EXTENSION_ID2], + `Additional data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID2].values), + 1, + `Additional data recorded for histogram: ${id}.` + ); + } + + await extension2.unload(); + + // Run a content script. + process = IS_OOP ? "content" : "parent"; + let expectedCount = IS_OOP ? 1 : 3; + let expectedKeyedCount = IS_OOP ? 1 : 2; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/file_sample.html` + ); + await extension1.awaitMessage("contentDone"); + + for (let id of expectedNonEmptyHistograms) { + await promiseTelemetryRecorded(id, process, expectedCount); + } + for (let id of expectedNonEmptyKeyedHistograms) { + await promiseKeyedTelemetryRecorded( + id, + process, + EXTENSION_ID1, + expectedKeyedCount + ); + } + + // Telemetry from extension1's content script should be recorded. + snapshots = getSnapshots(process); + keyedSnapshots = getKeyedSnapshots(process); + + for (let id of expectedNonEmptyHistograms) { + equal( + valueSum(snapshots[id].values), + expectedCount, + `Data recorded in content script for histogram: ${id}.` + ); + } + + for (let id of expectedNonEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id]).sort(), + IS_OOP ? [EXTENSION_ID1] : [EXTENSION_ID1, EXTENSION_ID2], + `Additional data recorded for histogram: ${id}.` + ); + equal( + valueSum(keyedSnapshots[id][EXTENSION_ID1].values), + expectedKeyedCount, + `Additional data recorded for histogram: ${id}.` + ); + } + + await extension1.unload(); + + // Telemetry for histograms that we expect to be empty. + for (let id of expectedEmptyHistograms) { + ok(!(id in snapshots), `No data recorded for histogram: ${id}.`); + } + + for (let id of expectedEmptyKeyedHistograms) { + Assert.deepEqual( + Object.keys(keyedSnapshots[id] || {}), + [], + `No data recorded for histogram: ${id}.` + ); + } + + await contentPage.close(); +} + +add_task(async function setup() { + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + // Telemetry test setup needed to ensure that the builtin events are defined + // and they can be collected and verified. + await TelemetryController.testSetup(); + + // This is actually only needed on Android, because it does not properly support unified telemetry + // and so, if not enabled explicitly here, it would make these tests to fail when running on a + // non-Nightly build. + const oldCanRecordBase = Services.telemetry.canRecordBase; + Services.telemetry.canRecordBase = true; + registerCleanupFunction(() => { + Services.telemetry.canRecordBase = oldCanRecordBase; + }); +}); + +add_task(function test_telemetry_background_file_backend() { + return runWithPrefs( + [[ExtensionStorageIDB.BACKEND_ENABLED_PREF, false]], + test_telemetry_background + ); +}); + +add_task(function test_telemetry_background_idb_backend() { + return runWithPrefs( + [ + [ExtensionStorageIDB.BACKEND_ENABLED_PREF, true], + // Set the migrated preference for the two test extension, because the + // first storage.local call fallbacks to run in the parent process when we + // don't know which is the selected backend during the extension startup + // and so we can't choose the telemetry histogram to use. + [ + `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID1}`, + true, + ], + [ + `${ExtensionStorageIDB.IDB_MIGRATED_PREF_BRANCH}.${EXTENSION_ID2}`, + true, + ], + ], + test_telemetry_background + ); +}); + +// This test verifies that we do record the expected telemetry event when we +// normalize the error message for an unexpected error (an error raised internally +// by the QuotaManager and/or IndexedDB, which it is being normalized into the generic +// "An unexpected error occurred" error message). +add_task(async function test_telemetry_storage_local_unexpected_error() { + // Clear any telemetry events collected so far. + Services.telemetry.clearEvents(); + + const methods = ["clear", "get", "remove", "set"]; + const veryLongErrorName = `VeryLongErrorName${Array(200) + .fill(0) + .join("")}`; + const otherError = new Error("an error recorded as OtherError"); + + const recordedErrors = [ + new DOMException("error message", "UnexpectedDOMException"), + new DOMException("error message", veryLongErrorName), + otherError, + ]; + + // We expect the following errors to not be recorded in telemetry (because they + // are raised on scenarios that we already expect). + const nonRecordedErrors = [ + new DOMException("error message", "QuotaExceededError"), + new DOMException("error message", "DataCloneError"), + ]; + + const expectedEvents = []; + + const errors = [].concat(recordedErrors, nonRecordedErrors); + + for (let i = 0; i < errors.length; i++) { + const error = errors[i]; + const storageMethod = methods[i] || "set"; + ExtensionStorageIDB.normalizeStorageError({ + error: errors[i], + extensionId: EXTENSION_ID1, + storageMethod, + }); + + if (recordedErrors.includes(error)) { + let error_name = + error === otherError ? "OtherError" : getTrimmedString(error.name); + + expectedEvents.push({ + value: EXTENSION_ID1, + object: storageMethod, + extra: { error_name }, + }); + } + } + + await TelemetryTestUtils.assertEvents(expectedEvents, { + category: "extensions.data", + method: "storageLocalError", + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js new file mode 100644 index 0000000000..ccbfddf6d2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_tab_teardown.js @@ -0,0 +1,98 @@ +"use strict"; + +add_task(async function test_extension_page_tabs_create_reload_and_close() { + let events = []; + { + let { Management } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm", + null + ); + let record = (type, extensionContext) => { + let eventType = type == "proxy-context-load" ? "load" : "unload"; + let url = extensionContext.uri.spec; + let extensionId = extensionContext.extension.id; + events.push({ eventType, url, extensionId }); + }; + + Management.on("proxy-context-load", record); + Management.on("proxy-context-unload", record); + registerCleanupFunction(() => { + Management.off("proxy-context-load", record); + Management.off("proxy-context-unload", record); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("tab-url", browser.runtime.getURL("page.html")); + }, + files: { + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + "page.js"() { + browser.test.sendMessage("extension page loaded", document.URL); + }, + }, + }); + + await extension.startup(); + let tabURL = await extension.awaitMessage("tab-url"); + events.splice(0); + + let contentPage = await ExtensionTestUtils.loadContentPage(tabURL, { + extension, + }); + let extensionPageURL = await extension.awaitMessage("extension page loaded"); + equal(extensionPageURL, tabURL, "Loaded the expected URL"); + + let contextEvents = events.splice(0); + equal(contextEvents.length, 1, "ExtensionContext change for opening a tab"); + equal(contextEvents[0].eventType, "load", "create ExtensionContext for tab"); + equal( + contextEvents[0].url, + extensionPageURL, + "ExtensionContext URL after tab creation should be tab URL" + ); + + await contentPage.spawn(null, () => { + this.content.location.reload(); + }); + let extensionPageURL2 = await extension.awaitMessage("extension page loaded"); + + equal( + extensionPageURL, + extensionPageURL2, + "The tab's URL is expected to not change after a page reload" + ); + + contextEvents = events.splice(0); + equal(contextEvents.length, 2, "ExtensionContext change after tab reload"); + equal(contextEvents[0].eventType, "unload", "unload old ExtensionContext"); + equal( + contextEvents[0].url, + extensionPageURL, + "ExtensionContext URL before reload should be tab URL" + ); + equal( + contextEvents[1].eventType, + "load", + "create new ExtensionContext for tab" + ); + equal( + contextEvents[1].url, + extensionPageURL2, + "ExtensionContext URL after reload should be tab URL" + ); + + await contentPage.close(); + + contextEvents = events.splice(0); + equal(contextEvents.length, 1, "ExtensionContext after closing tab"); + equal(contextEvents[0].eventType, "unload", "unload tab's ExtensionContext"); + equal( + contextEvents[0].url, + extensionPageURL2, + "ExtensionContext URL at closing tab should be tab URL" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js new file mode 100644 index 0000000000..8aa22f5a10 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_telemetry.js @@ -0,0 +1,870 @@ +"use strict"; + +const { TelemetryArchive } = ChromeUtils.import( + "resource://gre/modules/TelemetryArchive.jsm" +); +const { TelemetryUtils } = ChromeUtils.import( + "resource://gre/modules/TelemetryUtils.jsm" +); +const { TelemetryTestUtils } = ChromeUtils.import( + "resource://testing-common/TelemetryTestUtils.jsm" +); + +const { TelemetryArchiveTesting } = ChromeUtils.import( + "resource://testing-common/TelemetryArchiveTesting.jsm" +); + +const { TestUtils } = ChromeUtils.import( + "resource://testing-common/TestUtils.jsm" +); + +// All tests run privileged unless otherwise specified not to. +function createExtension( + backgroundScript, + permissions, + isPrivileged = true, + telemetry +) { + let extensionData = { + background: backgroundScript, + manifest: { permissions, telemetry }, + isPrivileged, + }; + + return ExtensionTestUtils.loadExtension(extensionData); +} + +async function run(test) { + let extension = createExtension( + test.backgroundScript, + test.permissions || ["telemetry"], + test.isPrivileged, + test.telemetry + ); + await extension.startup(); + await extension.awaitFinish(test.doneSignal); + await extension.unload(); +} + +// Currently unsupported on Android: blocked on 1220177. +// See 1280234 c67 for discussion. +if (AppConstants.MOZ_BUILD_APP === "browser") { + add_task(async function test_telemetry_without_telemetry_permission() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.telemetry, + "'telemetry' permission is required" + ); + browser.test.notifyPass("telemetry_permission"); + }, + permissions: [], + doneSignal: "telemetry_permission", + isPrivileged: false, + }); + }); + + add_task( + async function test_telemetry_without_telemetry_permission_privileged() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.telemetry, + "'telemetry' permission is required" + ); + browser.test.notifyPass("telemetry_permission"); + }, + permissions: [], + doneSignal: "telemetry_permission", + }); + } + ); + + add_task(async function test_telemetry_scalar_add() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd( + "telemetry.test.unsigned_int_kind", + 1 + ); + browser.test.notifyPass("scalar_add"); + }, + doneSignal: "scalar_add", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.unsigned_int_kind", + 1 + ); + }); + + add_task(async function test_telemetry_scalar_add_unknown_name() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd("telemetry.test.does_not_exist", 1); + browser.test.notifyPass("scalar_add_unknown_name"); + }, + doneSignal: "scalar_add_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is incremented" + ); + }); + + add_task(async function test_telemetry_scalar_add_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.scalarAdd("telemetry.test.unsigned_int_kind", {}), + /Incorrect argument types for telemetry.scalarAdd/, + "The second 'value' argument to scalarAdd must be an integer, string, or boolean" + ); + browser.test.notifyPass("scalar_add_illegal_value"); + }, + doneSignal: "scalar_add_illegal_value", + }); + }); + + add_task(async function test_telemetry_scalar_add_invalid_keyed_scalar() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarAdd( + "telemetry.test.keyed_unsigned_int", + 1 + ); + browser.test.notifyPass("scalar_add_invalid_keyed_scalar"); + }, + doneSignal: "scalar_add_invalid_keyed_scalar", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("Attempting to manage a keyed scalar as a scalar") + ), + "Telemetry should warn if a scalarAdd is called for a keyed scalar" + ); + }); + + add_task(async function test_telemetry_scalar_set() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet("telemetry.test.boolean_kind", true); + browser.test.notifyPass("scalar_set"); + }, + doneSignal: "scalar_set", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.boolean_kind", + true + ); + }); + + add_task(async function test_telemetry_scalar_set_unknown_name() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSet( + "telemetry.test.does_not_exist", + true + ); + browser.test.notifyPass("scalar_set_unknown_name"); + }, + doneSignal: "scalar_set_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is set" + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSetMaximum( + "telemetry.test.unsigned_int_kind", + 123 + ); + browser.test.notifyPass("scalar_set_maximum"); + }, + doneSignal: "scalar_set_maximum", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.unsigned_int_kind", + 123 + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum_unknown_name() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.scalarSetMaximum( + "telemetry.test.does_not_exist", + 1 + ); + browser.test.notifyPass("scalar_set_maximum_unknown_name"); + }, + doneSignal: "scalar_set_maximum_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown scalar is set" + ); + }); + + add_task(async function test_telemetry_scalar_set_maximum_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.scalarSetMaximum( + "telemetry.test.unsigned_int_kind", + "string" + ), + /Incorrect argument types for telemetry.scalarSetMaximum/, + "The second 'value' argument to scalarSetMaximum must be a scalar" + ); + browser.test.notifyPass("scalar_set_maximum_illegal_value"); + }, + doneSignal: "scalar_set_maximum_illegal_value", + }); + }); + + add_task(async function test_telemetry_keyed_scalar_add() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add"); + }, + doneSignal: "keyed_scalar_add", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_unsigned_int", + "foo", + 1 + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_unknown_name() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.does_not_exist", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add_unknown_name"); + }, + doneSignal: "keyed_scalar_add_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is incremented" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "foo", + {} + ), + /Incorrect argument types for telemetry.keyedScalarAdd/, + "The second 'value' argument to keyedScalarAdd must be an integer, string, or boolean" + ); + browser.test.notifyPass("keyed_scalar_add_illegal_value"); + }, + doneSignal: "keyed_scalar_add_illegal_value", + }); + }); + + add_task(async function test_telemetry_keyed_scalar_add_invalid_scalar() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.unsigned_int_kind", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_add_invalid_scalar"); + }, + doneSignal: "keyed_scalar_add_invalid_scalar", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes( + "Attempting to manage a keyed scalar as a scalar (or vice-versa)" + ) + ), + "Telemetry should warn if a scalar is incremented as a keyed scalar" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_add_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarAdd( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_add_long_key"); + }, + doneSignal: "keyed_scalar_add_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters.") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.keyed_boolean_kind", + "foo", + true + ); + browser.test.notifyPass("keyed_scalar_set"); + }, + doneSignal: "keyed_scalar_set", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_boolean_kind", + "foo", + true + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_unknown_name() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.does_not_exist", + "foo", + true + ); + browser.test.notifyPass("keyed_scalar_set_unknown_name"); + }, + doneSignal: "keyed_scalar_set_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is incremented" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSet( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_set_long_key"); + }, + doneSignal: "keyed_scalar_set_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_keyed_scalar_set_maximum() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "foo", + 123 + ); + browser.test.notifyPass("keyed_scalar_set_maximum"); + }, + doneSignal: "keyed_scalar_set_maximum", + }); + TelemetryTestUtils.assertKeyedScalar( + TelemetryTestUtils.getProcessScalars("parent", true, true), + "telemetry.test.keyed_unsigned_int", + "foo", + 123 + ); + }); + + add_task( + async function test_telemetry_keyed_scalar_set_maximum_unknown_name() { + let { messages } = await promiseConsoleOutput(async function() { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.does_not_exist", + "foo", + 1 + ); + browser.test.notifyPass("keyed_scalar_set_maximum_unknown_name"); + }, + doneSignal: "keyed_scalar_set_maximum_unknown_name", + }); + }); + Assert.ok( + messages.find(({ message }) => message.includes("Unknown scalar")), + "Telemetry should warn if an unknown keyed scalar is set" + ); + } + ); + + add_task( + async function test_telemetry_keyed_scalar_set_maximum_illegal_value() { + await run({ + backgroundScript: () => { + browser.test.assertThrows( + () => + browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "foo", + "string" + ), + /Incorrect argument types for telemetry.keyedScalarSetMaximum/, + "The third 'value' argument to keyedScalarSetMaximum must be a scalar" + ); + browser.test.notifyPass("keyed_scalar_set_maximum_illegal_value"); + }, + doneSignal: "keyed_scalar_set_maximum_illegal_value", + }); + } + ); + + add_task(async function test_telemetry_keyed_scalar_set_maximum_long_key() { + let { messages } = await promiseConsoleOutput(async () => { + await run({ + backgroundScript: async () => { + await browser.telemetry.keyedScalarSetMaximum( + "telemetry.test.keyed_unsigned_int", + "X".repeat(73), + 1 + ); + browser.test.notifyPass("keyed_scalar_set_maximum_long_key"); + }, + doneSignal: "keyed_scalar_set_maximum_long_key", + }); + }); + Assert.ok( + messages.find(({ message }) => + message.includes("The key length must be limited to 72 characters") + ), + "Telemetry should warn if keyed scalar's key is too long" + ); + }); + + add_task(async function test_telemetry_record_event() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("telemetry.test", true); + + await run({ + backgroundScript: async () => { + await browser.telemetry.recordEvent( + "telemetry.test", + "test1", + "object1" + ); + browser.test.notifyPass("record_event_ok"); + }, + doneSignal: "record_event_ok", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test", + method: "test1", + object: "object1", + }, + ], + { category: "telemetry.test" } + ); + + Services.telemetry.setEventRecordingEnabled("telemetry.test", false); + Services.telemetry.clearEvents(); + }); + + // Bug 1536877 + add_task(async function test_telemetry_record_event_value_must_be_string() { + Services.telemetry.clearEvents(); + Services.telemetry.setEventRecordingEnabled("telemetry.test", true); + + await run({ + backgroundScript: async () => { + try { + await browser.telemetry.recordEvent( + "telemetry.test", + "test1", + "object1", + "value1" + ); + browser.test.notifyPass("record_event_string_value"); + } catch (ex) { + browser.test.fail( + `Unexpected exception raised during record_event_value_must_be_string: ${ex}` + ); + browser.test.notifyPass("record_event_string_value"); + throw ex; + } + }, + doneSignal: "record_event_string_value", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test", + method: "test1", + object: "object1", + value: "value1", + }, + ], + { category: "telemetry.test" } + ); + + Services.telemetry.setEventRecordingEnabled("telemetry.test", false); + Services.telemetry.clearEvents(); + }); + + add_task(async function test_telemetry_register_scalars_string() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_string: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string", + "hello" + ); + browser.test.notifyPass("register_scalars_string"); + }, + doneSignal: "register_scalars_string", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("parent", false, true), + "telemetry.test.dynamic.webext_string", + "hello" + ); + }); + + add_task(async function test_telemetry_register_scalars_multiple() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_string: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + webext_string_too: { + kind: browser.telemetry.ScalarType.STRING, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string", + "hello" + ); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_string_too", + "world" + ); + browser.test.notifyPass("register_scalars_multiple"); + }, + doneSignal: "register_scalars_multiple", + }); + const scalars = TelemetryTestUtils.getProcessScalars("parent", false, true); + TelemetryTestUtils.assertScalar( + scalars, + "telemetry.test.dynamic.webext_string", + "hello" + ); + TelemetryTestUtils.assertScalar( + scalars, + "telemetry.test.dynamic.webext_string_too", + "world" + ); + }); + + add_task(async function test_telemetry_register_scalars_boolean() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_boolean: { + kind: browser.telemetry.ScalarType.BOOLEAN, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_boolean", + true + ); + browser.test.notifyPass("register_scalars_boolean"); + }, + doneSignal: "register_scalars_boolean", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("dynamic", false, true), + "telemetry.test.dynamic.webext_boolean", + true + ); + }); + + add_task(async function test_telemetry_register_scalars_count() { + Services.telemetry.clearScalars(); + await run({ + backgroundScript: async () => { + await browser.telemetry.registerScalars("telemetry.test.dynamic", { + webext_count: { + kind: browser.telemetry.ScalarType.COUNT, + keyed: false, + record_on_release: true, + }, + }); + await browser.telemetry.scalarSet( + "telemetry.test.dynamic.webext_count", + 123 + ); + browser.test.notifyPass("register_scalars_count"); + }, + doneSignal: "register_scalars_count", + }); + TelemetryTestUtils.assertScalar( + TelemetryTestUtils.getProcessScalars("dynamic", false, true), + "telemetry.test.dynamic.webext_count", + 123 + ); + }); + + add_task(async function test_telemetry_register_events() { + Services.telemetry.clearEvents(); + + await run({ + backgroundScript: async () => { + await browser.telemetry.registerEvents("telemetry.test.dynamic", { + test1: { + methods: ["test1"], + objects: ["object1"], + extra_keys: [], + }, + }); + await browser.telemetry.recordEvent( + "telemetry.test.dynamic", + "test1", + "object1" + ); + browser.test.notifyPass("register_events"); + }, + doneSignal: "register_events", + }); + + TelemetryTestUtils.assertEvents( + [ + { + category: "telemetry.test.dynamic", + method: "test1", + object: "object1", + }, + ], + { category: "telemetry.test.dynamic" }, + { process: "dynamic" } + ); + }); + + add_task(async function test_telemetry_submit_ping() { + let archiveTester = new TelemetryArchiveTesting.Checker(); + await archiveTester.promiseInit(); + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitPing("webext-test", {}, {}); + browser.test.notifyPass("submit_ping"); + }, + doneSignal: "submit_ping", + }); + + await TestUtils.waitForCondition( + () => archiveTester.promiseFindPing("webext-test", []), + "Failed to find the webext-test ping" + ); + }); + + add_task(async function test_telemetry_submit_encrypted_ping() { + await run({ + backgroundScript: async () => { + try { + await browser.telemetry.submitEncryptedPing( + { payload: "encrypted-webext-test" }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.fail( + "Expected exception without required manifest entries set." + ); + } catch (e) { + browser.test.assertTrue( + e, + /Encrypted telemetry pings require ping_type and public_key to be set in manifest./ + ); + browser.test.notifyPass("submit_encrypted_ping_fail"); + } + }, + doneSignal: "submit_encrypted_ping_fail", + }); + + const telemetryManifestEntries = { + ping_type: "encrypted-webext-ping", + schemaNamespace: "schema-namespace", + public_key: { + id: "pioneer-dev-20200423", + key: { + crv: "P-256", + kty: "EC", + x: "Qqihp7EryDN2-qQ-zuDPDpy5mJD5soFBDZmzPWTmjwk", + y: "PiEQVUlywi2bEsA3_5D0VFrCHClCyUlLW52ajYs-5uc", + }, + }, + }; + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitEncryptedPing( + { + payload: "encrypted-webext-test", + }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.notifyPass("submit_encrypted_ping_pass"); + }, + permissions: ["telemetry"], + doneSignal: "submit_encrypted_ping_pass", + isPrivileged: true, + telemetry: telemetryManifestEntries, + }); + + telemetryManifestEntries.pioneer_id = true; + telemetryManifestEntries.study_name = "test123"; + Services.prefs.setStringPref("toolkit.telemetry.pioneerId", "test123"); + + await run({ + backgroundScript: async () => { + await browser.telemetry.submitEncryptedPing( + { payload: "encrypted-webext-test" }, + { + schemaName: "schema-name", + schemaVersion: 123, + } + ); + browser.test.notifyPass("submit_encrypted_ping_pass"); + }, + permissions: ["telemetry"], + doneSignal: "submit_encrypted_ping_pass", + isPrivileged: true, + telemetry: telemetryManifestEntries, + }); + + let pings; + await TestUtils.waitForCondition(async function() { + pings = await TelemetryArchive.promiseArchivedPingList(); + return pings.length >= 3; + }, "Wait until we have at least 3 pings in the telemetry archive"); + + equal(pings.length, 3); + equal(pings[1].type, "encrypted-webext-ping"); + equal(pings[2].type, "encrypted-webext-ping"); + }); + + add_task(async function test_telemetry_can_upload_enabled() { + Services.prefs.setBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + true + ); + + await run({ + backgroundScript: async () => { + const result = await browser.telemetry.canUpload(); + browser.test.assertTrue(result); + browser.test.notifyPass("can_upload_enabled"); + }, + doneSignal: "can_upload_enabled", + }); + + Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled); + }); + + add_task(async function test_telemetry_can_upload_disabled() { + Services.prefs.setBoolPref( + TelemetryUtils.Preferences.FhrUploadEnabled, + false + ); + + await run({ + backgroundScript: async () => { + const result = await browser.telemetry.canUpload(); + browser.test.assertFalse(result); + browser.test.notifyPass("can_upload_disabled"); + }, + doneSignal: "can_upload_disabled", + }); + + Services.prefs.clearUserPref(TelemetryUtils.Preferences.FhrUploadEnabled); + }); +} diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js new file mode 100644 index 0000000000..81e07d9a9b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_mock.js @@ -0,0 +1,55 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test verifies that the extension mocks behave consistently, regardless +// of test type (xpcshell vs browser test). +// See also toolkit/components/extensions/test/browser/browser_ext_test_mock.js + +// Check the state of the extension object. This should be consistent between +// browser tests and xpcshell tests. +async function checkExtensionStartupAndUnload(ext) { + await ext.startup(); + Assert.ok(ext.id, "Extension ID should be available"); + Assert.ok(ext.uuid, "Extension UUID should be available"); + await ext.unload(); + // Once set nothing clears the UUID. + Assert.ok(ext.uuid, "Extension UUID exists after unload"); +} + +AddonTestUtils.init(this); + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task(async function test_MockExtension() { + let ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: {}, + }); + + Assert.equal(ext.constructor.name, "InstallableWrapper", "expected class"); + Assert.ok(!ext.id, "Extension ID is initially unavailable"); + Assert.ok(!ext.uuid, "Extension UUID is initially unavailable"); + await checkExtensionStartupAndUnload(ext); + // When useAddonManager is set, AOMExtensionWrapper clears the ID upon unload. + // TODO: Fix AOMExtensionWrapper to not clear the ID after unload, and move + // this assertion inside |checkExtensionStartupAndUnload| (since then the + // behavior will be consistent across all test types). + Assert.ok(!ext.id, "Extension ID is cleared after unload"); +}); + +add_task(async function test_generated_Extension() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: {}, + }); + + Assert.equal(ext.constructor.name, "ExtensionWrapper", "expected class"); + // Without "useAddonManager", an Extension is generated and their IDs are + // immediately available. + Assert.ok(ext.id, "Extension ID is initially available"); + Assert.ok(ext.uuid, "Extension UUID is initially available"); + await checkExtensionStartupAndUnload(ext); + Assert.ok(ext.id, "Extension ID exists after unload"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js new file mode 100644 index 0000000000..1752b5a2b5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_test_wrapper.js @@ -0,0 +1,64 @@ +"use strict"; + +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +// Automatically start the background page after restarting the AddonManager. +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + false +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +const TEST_ADDON_ID = "@some-permanent-test-addon"; + +// Load a permanent extension that eventually unloads the extension immediately +// after add-on startup, to set the stage as a regression test for bug 1575190. +add_task(async function setup_wrapper() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + applications: { gecko: { id: TEST_ADDON_ID } }, + }, + background() { + browser.test.sendMessage("started_up"); + }, + }); + + await AddonTestUtils.promiseStartupManager(); + await extension.startup(); + await AddonTestUtils.promiseShutdownManager(); + + // Check message because it is expected to be received while `startup()` was + // pending resolution. + info("Awaiting expected started_up message 1"); + await extension.awaitMessage("started_up"); + + // Load AddonManager, and unload the extension as soon as it has started. + await AddonTestUtils.promiseStartupManager(); + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + + // Confirm that the extension has started when promiseStartupManager returned. + info("Awaiting expected started_up message 2"); + await extension.awaitMessage("started_up"); +}); + +// Check that the add-on from the previous test has indeed been uninstalled. +add_task(async function restart_addon_manager_after_extension_unload() { + await AddonTestUtils.promiseStartupManager(); + let addon = await AddonManager.getAddonByID(TEST_ADDON_ID); + equal(addon, null, "Test add-on should have been removed"); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js new file mode 100644 index 0000000000..f509ae1749 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_trustworthy_origin.js @@ -0,0 +1,20 @@ +"use strict"; + +/** + * This test is asserting that moz-extension: URLs are recognized as trustworthy local origins + */ + +add_task( + function test_isOriginPotentiallyTrustworthnsIContentSecurityManagery() { + let uri = NetUtil.newURI("moz-extension://foobar/something.html"); + let principal = Services.scriptSecurityManager.createContentPrincipal( + uri, + {} + ); + Assert.equal( + principal.isOriginPotentiallyTrustworthy, + true, + "it is potentially trustworthy" + ); + } +); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js new file mode 100644 index 0000000000..9c515520e1 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unknown_permissions.js @@ -0,0 +1,63 @@ +"use strict"; + +// This test expects and checks warnings for unknown permissions. +ExtensionTestUtils.failOnSchemaWarnings(false); + +add_task(async function test_unknown_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "activeTab", + "fooUnknownPermission", + "http://*/", + "chrome://favicon/", + ], + optional_permissions: ["chrome://favicon/", "https://example.com/"], + }, + }); + + let { messages } = await promiseConsoleOutput(() => extension.startup()); + + const { WebExtensionPolicy } = Cu.getGlobalForObject( + ChromeUtils.import("resource://gre/modules/Extension.jsm", {}) + ); + + let policy = WebExtensionPolicy.getByID(extension.id); + Assert.deepEqual(Array.from(policy.permissions).sort(), [ + "activeTab", + "http://*/*", + ]); + + Assert.deepEqual(extension.extension.manifest.optional_permissions, [ + "https://example.com/", + ]); + + ok( + messages.some(message => + /Error processing permissions\.1: Value "fooUnknownPermission" must/.test( + message + ) + ), + 'Got expected error for "fooUnknownPermission"' + ); + + ok( + messages.some(message => + /Error processing permissions\.3: Value "chrome:\/\/favicon\/" must/.test( + message + ) + ), + 'Got expected error for "chrome://favicon/"' + ); + + ok( + messages.some(message => + /Error processing optional_permissions\.0: Value "chrome:\/\/favicon\/" must/.test( + message + ) + ), + "Got expected error from optional_permissions" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js new file mode 100644 index 0000000000..6a9125c9e4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unlimitedStorage.js @@ -0,0 +1,213 @@ +"use strict"; + +const { + createAppInfo, + promiseStartupManager, + promiseRestartManager, + promiseWebExtensionStartup, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +const STORAGE_SITE_PERMISSIONS = [ + "WebExtensions-unlimitedStorage", + "indexedDB", + "persistent-storage", +]; + +function checkSitePermissions(principal, expectedPermAction, assertMessage) { + for (const permName of STORAGE_SITE_PERMISSIONS) { + const actualPermAction = Services.perms.testPermissionFromPrincipal( + principal, + permName + ); + + equal( + actualPermAction, + expectedPermAction, + `The extension "${permName}" SitePermission ${assertMessage} as expected` + ); + } +} + +add_task(async function test_unlimitedStorage_restored_on_app_startup() { + const id = "test-unlimitedStorage-removed-on-app-shutdown@mozilla"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unlimitedStorage"], + applications: { + gecko: { id }, + }, + }, + + useAddonManager: "permanent", + }); + + await promiseStartupManager(); + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + const principal = policy.extension.principal; + + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + // Remove site permissions as it would happen if Firefox is shutting down + // with the "clear site permissions" setting. + + Services.perms.removeFromPrincipal( + principal, + "WebExtensions-unlimitedStorage" + ); + Services.perms.removeFromPrincipal(principal, "indexedDB"); + Services.perms.removeFromPrincipal(principal, "persistent-storage"); + + checkSitePermissions(principal, Services.perms.UNKNOWN_ACTION, "is not set"); + + const onceExtensionStarted = promiseWebExtensionStartup(id); + await promiseRestartManager(); + await onceExtensionStarted; + + // The site permissions should have been granted again. + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + await extension.unload(); +}); + +add_task(async function test_unlimitedStorage_removed_on_update() { + const id = "test-unlimitedStorage-removed-on-update@mozilla"; + + function background() { + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "set-storage": + browser.test.log(`storing data in storage.local`); + await browser.storage.local.set({ akey: "somevalue" }); + browser.test.log(`data stored in storage.local successfully`); + break; + case "has-storage": { + browser.test.log(`checking data stored in storage.local`); + const data = await browser.storage.local.get(["akey"]); + browser.test.assertEq( + data.akey, + "somevalue", + "Got storage.local data" + ); + break; + } + default: + browser.test.fail(`Unexpected test message: ${msg}`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["unlimitedStorage", "storage"], + applications: { gecko: { id } }, + version: "1", + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + const policy = WebExtensionPolicy.getByID(extension.id); + const principal = policy.extension.principal; + + checkSitePermissions( + principal, + Services.perms.ALLOW_ACTION, + "has been allowed" + ); + + extension.sendMessage("set-storage"); + await extension.awaitMessage("set-storage:done"); + extension.sendMessage("has-storage"); + await extension.awaitMessage("has-storage:done"); + + // Simulate an update which do not require the unlimitedStorage permission. + await extension.upgrade({ + background, + manifest: { + permissions: ["storage"], + applications: { gecko: { id } }, + version: "2", + }, + useAddonManager: "permanent", + }); + + const newPolicy = WebExtensionPolicy.getByID(extension.id); + const newPrincipal = newPolicy.extension.principal; + + equal( + principal.spec, + newPrincipal.spec, + "upgraded extension has the expected principal" + ); + + checkSitePermissions( + principal, + Services.perms.UNKNOWN_ACTION, + "has been cleared" + ); + + // Verify that the previously stored data has not been + // removed as a side effect of removing the unlimitedStorage + // permission. + extension.sendMessage("has-storage"); + await extension.awaitMessage("has-storage:done"); + + await extension.unload(); +}); + +add_task(async function test_unlimitedStorage_origin_attributes() { + Services.prefs.setBoolPref("privacy.firstparty.isolate", true); + + const id = "test-unlimitedStorage-origin-attributes@mozilla"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["unlimitedStorage"], + applications: { gecko: { id } }, + }, + }); + + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + + ok( + !principal.firstPartyDomain, + "extension principal has no firstPartyDomain" + ); + + let perm = Services.perms.testExactPermissionFromPrincipal( + principal, + "persistent-storage" + ); + equal( + perm, + Services.perms.ALLOW_ACTION, + "Should have the correct permission without OAs" + ); + + await extension.unload(); + + Services.prefs.clearUserPref("privacy.firstparty.isolate"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js new file mode 100644 index 0000000000..058e8b7371 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_unload_frame.js @@ -0,0 +1,230 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +// Background and content script for testSendMessage_* +function sendMessage_background(delayedNotifyPass) { + browser.runtime.onMessage.addListener((msg, sender, sendResponse) => { + browser.test.assertEq("from frame", msg, "Expected message from frame"); + sendResponse("msg from back"); // Should not throw or anything like that. + delayedNotifyPass("Received sendMessage from closing frame"); + }); +} +function sendMessage_contentScript(testType) { + browser.runtime.sendMessage("from frame", reply => { + // The frame has been removed, so we should not get this callback! + browser.test.fail(`Unexpected reply: ${reply}`); + }); + if (testType == "frame") { + frameElement.remove(); + } else { + browser.test.sendMessage("close-window"); + } +} + +// Background and content script for testConnect_* +function connect_background(delayedNotifyPass) { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("port from frame", port.name); + + let disconnected = false; + let hasMessage = false; + port.onDisconnect.addListener(() => { + browser.test.assertFalse(disconnected, "onDisconnect should fire once"); + disconnected = true; + browser.test.assertTrue( + hasMessage, + "Expected onMessage before onDisconnect" + ); + browser.test.assertEq( + null, + port.error, + "The port is implicitly closed without errors when the other context unloads" + ); + delayedNotifyPass("Received onDisconnect from closing frame"); + }); + port.onMessage.addListener(msg => { + browser.test.assertFalse(hasMessage, "onMessage should fire once"); + hasMessage = true; + browser.test.assertFalse( + disconnected, + "Should get message before disconnect" + ); + browser.test.assertEq("from frame", msg, "Expected message from frame"); + }); + + port.postMessage("reply to closing frame"); + }); +} +function connect_contentScript(testType) { + let isUnloading = false; + addEventListener( + "pagehide", + () => { + isUnloading = true; + }, + { once: true } + ); + + let port = browser.runtime.connect({ name: "port from frame" }); + port.onMessage.addListener(msg => { + // The background page sends a reply as soon as we call runtime.connect(). + // It is possible that the reply reaches this frame before the + // window.close() request has been processed. + if (!isUnloading) { + browser.test.log( + `Ignorting unexpected reply ("${msg}") because the page is not being unloaded.` + ); + return; + } + + // The frame has been removed, so we should not get a reply. + browser.test.fail(`Unexpected reply: ${msg}`); + }); + port.postMessage("from frame"); + + // Removing the frame or window should disconnect the port. + if (testType == "frame") { + frameElement.remove(); + } else { + browser.test.sendMessage("close-window"); + } +} + +// `testType` is "window" or "frame". +function createTestExtension(testType, backgroundScript, contentScript) { + // Make a roundtrip between the background page and the test runner (which is + // in the same process as the content script) to make sure that we record a + // failure in case the content script's sendMessage or onMessage handlers are + // called even after the frame or window was removed. + function delayedNotifyPass(msg) { + browser.test.onMessage.addListener((type, echoMsg) => { + if (type == "pong") { + browser.test.assertEq(msg, echoMsg, "Echoed reply should be the same"); + browser.test.notifyPass(msg); + } + }); + browser.test.log("Starting ping-pong to flush messages..."); + browser.test.sendMessage("ping", msg); + } + let extension = ExtensionTestUtils.loadExtension({ + background: `(${backgroundScript})(${delayedNotifyPass});`, + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + all_frames: testType == "frame", + matches: ["http://example.com/data/file_sample.html"], + }, + ], + }, + files: { + "contentscript.js": `(${contentScript})("${testType}");`, + }, + }); + extension.awaitMessage("ping").then(msg => { + extension.sendMessage("pong", msg); + }); + return extension; +} + +add_task(async function testSendMessage_and_remove_frame() { + let extension = createTestExtension( + "frame", + sendMessage_background, + sendMessage_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await contentPage.spawn(null, () => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = "/data/file_sample.html"; + document.body.appendChild(frame); + }); + + await extension.awaitFinish("Received sendMessage from closing frame"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function testConnect_and_remove_frame() { + let extension = createTestExtension( + "frame", + connect_background, + connect_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + + await contentPage.spawn(null, () => { + let { document } = this.content; + let frame = document.createElement("iframe"); + frame.src = "/data/file_sample.html"; + document.body.appendChild(frame); + }); + + await extension.awaitFinish("Received onDisconnect from closing frame"); + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function testSendMessage_and_remove_window() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // We can't rely on this timing on Android. + return; + } + + let extension = createTestExtension( + "window", + sendMessage_background, + sendMessage_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("close-window"); + await contentPage.close(); + + await extension.awaitFinish("Received sendMessage from closing frame"); + await extension.unload(); +}); + +add_task(async function testConnect_and_remove_window() { + if (AppConstants.MOZ_BUILD_APP !== "browser") { + // We can't rely on this timing on Android. + return; + } + + let extension = createTestExtension( + "window", + connect_background, + connect_contentScript + ); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("close-window"); + await contentPage.close(); + + await extension.awaitFinish("Received onDisconnect from closing frame"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js new file mode 100644 index 0000000000..78114d9de4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts.js @@ -0,0 +1,671 @@ +"use strict"; + +const PROCESS_COUNT_PREF = "dom.ipc.processCount"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function setup_test_environment() { + if (ExtensionTestUtils.remoteContentScripts) { + // Start with one content process so that we can increase the number + // later and test the behavior of a fresh content process. + Services.prefs.setIntPref(PROCESS_COUNT_PREF, 1); + } + + // Grant the optional permissions requested. + function permissionObserver(subject, topic, data) { + if (topic == "webextension-optional-permission-prompt") { + let { resolve } = subject.wrappedJSObject; + resolve(true); + } + } + Services.obs.addObserver( + permissionObserver, + "webextension-optional-permission-prompt" + ); + registerCleanupFunction(() => { + Services.obs.removeObserver( + permissionObserver, + "webextension-optional-permission-prompt" + ); + }); +}); + +// Test that there is no userScripts API namespace when the manifest doesn't include a user_scripts +// property. +add_task(async function test_userScripts_manifest_property_required() { + function background() { + browser.test.assertEq( + undefined, + browser.userScripts, + "userScripts API namespace should be undefined in the extension page" + ); + browser.test.sendMessage("background-page:done"); + } + + async function contentScript() { + browser.test.assertEq( + undefined, + browser.userScripts, + "userScripts API namespace should be undefined in the content script" + ); + browser.test.sendMessage("content-script:done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + await extension.awaitMessage("background-page:done"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("content-script:done"); + + await extension.unload(); + await contentPage.close(); +}); + +// Test that userScripts can only matches origins that are subsumed by the extension permissions, +// and that more origins can be allowed by requesting an optional permission. +add_task(async function test_userScripts_matches_denied() { + async function background() { + async function registerUserScriptWithMatches(matches) { + const scripts = await browser.userScripts.register({ + js: [{ code: "" }], + matches, + }); + await scripts.unregister(); + } + + // These matches are supposed to be denied until the extension has been granted the + // <all_urls> origin permission. + const testMatches = [ + "<all_urls>", + "file://*/*", + "https://localhost/*", + "http://example.com/*", + ]; + + browser.test.onMessage.addListener(async msg => { + if (msg === "test-denied-matches") { + for (let testMatch of testMatches) { + await browser.test.assertRejects( + registerUserScriptWithMatches([testMatch]), + /Permission denied to register a user script for/, + "Got the expected rejection when the extension permission does not subsume the userScript matches" + ); + } + } else if (msg === "grant-all-urls") { + await browser.permissions.request({ origins: ["<all_urls>"] }); + } else if (msg === "test-allowed-matches") { + for (let testMatch of testMatches) { + try { + await registerUserScriptWithMatches([testMatch]); + } catch (err) { + browser.test.fail( + `Unexpected rejection ${err} on matching ${JSON.stringify( + testMatch + )}` + ); + } + } + } else { + browser.test.fail(`Received an unexpected ${msg} test message`); + } + + browser.test.sendMessage(`${msg}:done`); + }); + + browser.test.sendMessage("background-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*"], + optional_permissions: ["<all_urls>"], + user_scripts: {}, + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("background-ready"); + + // Test that the matches not subsumed by the extension permissions are being denied. + extension.sendMessage("test-denied-matches"); + await extension.awaitMessage("test-denied-matches:done"); + + // Grant the optional <all_urls> permission. + await withHandlingUserInput(extension, async () => { + extension.sendMessage("grant-all-urls"); + await extension.awaitMessage("grant-all-urls:done"); + }); + + // Test that all the matches are now subsumed by the extension permissions. + extension.sendMessage("test-allowed-matches"); + await extension.awaitMessage("test-allowed-matches:done"); + + await extension.unload(); +}); + +// Test that userScripts sandboxes: +// - can be registered/unregistered from an extension page (and they are registered on both new and +// existing processes). +// - have no WebExtensions APIs available +// - are able to access the target window and document +add_task(async function test_userScripts_no_webext_apis() { + async function background() { + const matches = ["http://localhost/*/file_sample.html*"]; + + const sharedCode = { + code: 'console.log("js code shared by multiple userScripts");', + }; + + const userScriptOptions = { + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined; + document.body.innerHTML = "userScript loaded - " + JSON.stringify(webextAPINamespaces); + }, {once: true}); + `, + }, + ], + runAt: "document_start", + matches, + scriptMetadata: { + name: "test-user-script", + arrayProperty: ["el1"], + objectProperty: { nestedProp: "nestedValue" }, + nullProperty: null, + }, + }; + + let script = await browser.userScripts.register(userScriptOptions); + + // Unregister and then register the same js code again, to verify that the last registered + // userScript doesn't get assigned a revoked blob url (otherwise Extensioncontent.jsm + // ScriptCache raises an error because it fails to compile the revoked blob url and the user + // script will never be loaded). + script.unregister(); + script = await browser.userScripts.register(userScriptOptions); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "register-new-script") { + return; + } + + await script.unregister(); + await browser.userScripts.register({ + ...userScriptOptions, + scriptMetadata: { name: "test-new-script" }, + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined; + document.body.innerHTML = "new userScript loaded - " + JSON.stringify(webextAPINamespaces); + }, {once: true}); + `, + }, + ], + }); + + browser.test.sendMessage("script-registered"); + }); + + const scriptToRemove = await browser.userScripts.register({ + js: [ + sharedCode, + { + code: ` + window.addEventListener("load", () => { + document.body.innerHTML = "unexpected unregistered userScript loaded"; + }, {once: true}); + `, + }, + ], + runAt: "document_start", + matches, + scriptMetadata: { + name: "user-script-to-remove", + }, + }); + + browser.test.assertTrue( + "unregister" in script, + "Got an unregister method on the userScript API object" + ); + + // Remove the last registered user script. + await scriptToRemove.unregister(); + + browser.test.sendMessage("background-ready"); + } + + let extensionData = { + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: {}, + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + + await extension.awaitMessage("background-ready"); + + let url = `${BASE_URL}/file_sample.html?testpage=1`; + let contentPage = await ExtensionTestUtils.loadContentPage( + url, + ExtensionTestUtils.remoteContentScripts ? { remote: true } : undefined + ); + let result = await contentPage.spawn(undefined, async () => { + return { + textContent: this.content.document.body.textContent, + url: this.content.location.href, + readyState: this.content.document.readyState, + }; + }); + Assert.deepEqual( + result, + { + textContent: "userScript loaded - undefined", + url, + readyState: "complete", + }, + "The userScript executed on the expected url and no access to the WebExtensions APIs" + ); + + // If the tests is running with "remote content process" mode, test that the userScript + // are being correctly registered in newly created processes (received as part of the sharedData). + if (ExtensionTestUtils.remoteContentScripts) { + info( + "Test content script are correctly created on a newly created process" + ); + + await extension.sendMessage("register-new-script"); + await extension.awaitMessage("script-registered"); + + // Update the process count preference, so that we can test that the newly registered user script + // is propagated as expected into the newly created process. + Services.prefs.setIntPref(PROCESS_COUNT_PREF, 2); + + const url2 = `${BASE_URL}/file_sample.html?testpage=2`; + let contentPage2 = await ExtensionTestUtils.loadContentPage(url2, { + remote: true, + }); + let result2 = await contentPage2.spawn(undefined, async () => { + return { + textContent: this.content.document.body.textContent, + url: this.content.location.href, + readyState: this.content.document.readyState, + }; + }); + Assert.deepEqual( + result2, + { + textContent: "new userScript loaded - undefined", + url: url2, + readyState: "complete", + }, + "The userScript executed on the expected url and no access to the WebExtensions APIs" + ); + + await contentPage2.close(); + } + + await contentPage.close(); + + await extension.unload(); +}); + +// This test verify that a cached script is still able to catch the document +// while it is still loading (when we do not block the document parsing as +// we do for a non cached script). +add_task(async function test_cached_userScript_on_document_start() { + function apiScript() { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + sendTestMessage(name, params) { + return browser.test.sendMessage(name, params); + }, + }); + }); + } + + async function background() { + function userScript() { + this.sendTestMessage("user-script-loaded", { + url: window.location.href, + documentReadyState: document.readyState, + }); + } + + await browser.userScripts.register({ + js: [ + { + code: `(${userScript})();`, + }, + ], + runAt: "document_start", + matches: ["http://localhost/*/file_sample.html"], + }); + + browser.test.sendMessage("user-script-registered"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: { + api_script: "api-script.js", + // The following is an unexpected manifest property, that we expect to be ignored and + // to not prevent the test extension from being installed and run as expected. + unexpected_manifest_key: "test-unexpected-key", + }, + }, + background, + files: { + "api-script.js": apiScript, + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("user-script-registered"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + let msg = await extension.awaitMessage("user-script-loaded"); + Assert.deepEqual( + msg, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a non cached user script" + ); + + // Reload the page and check that the cached content script is still able to + // run on document_start. + await contentPage.loadURL(url); + + let msgFromCached = await extension.awaitMessage("user-script-loaded"); + Assert.deepEqual( + msgFromCached, + { + url, + documentReadyState: "loading", + }, + "Got the expected url and document.readyState from a cached user script" + ); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_userScripts_pref_disabled() { + async function run_userScript_on_pref_disabled_test() { + async function background() { + let promise = (async () => { + await browser.userScripts.register({ + js: [ + { + code: + "throw new Error('This userScripts should not be registered')", + }, + ], + runAt: "document_start", + matches: ["<all_urls>"], + }); + })(); + + await browser.test.assertRejects( + promise, + /userScripts APIs are currently experimental/, + "Got the expected error from userScripts.register when the userScripts API is disabled" + ); + + browser.test.sendMessage("background-page:done"); + } + + async function contentScript() { + let promise = (async () => { + browser.userScripts.onBeforeScript.addListener(() => {}); + })(); + await browser.test.assertRejects( + promise, + /userScripts APIs are currently experimental/, + "Got the expected error from userScripts.onBeforeScript when the userScripts API is disabled" + ); + + browser.test.sendMessage("content-script:done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { api_script: "" }, + content_scripts: [ + { + matches: ["http://*/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + }, + files: { + "content_script.js": contentScript, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-page:done"); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("content-script:done"); + + await extension.unload(); + await contentPage.close(); + } + + await runWithPrefs( + [["extensions.webextensions.userScripts.enabled", false]], + run_userScript_on_pref_disabled_test + ); +}); + +// This test verify that userScripts.onBeforeScript API Event is not available without +// a "user_scripts.api_script" property in the manifest. +add_task(async function test_user_script_api_script_required() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://localhost/*/file_sample.html"], + js: ["content_script.js"], + run_at: "document_start", + }, + ], + user_scripts: {}, + }, + files: { + "content_script.js": function() { + browser.test.assertEq( + undefined, + browser.userScripts && browser.userScripts.onBeforeScript, + "Got an undefined onBeforeScript property as expected" + ); + browser.test.sendMessage("no-onBeforeScript:done"); + }, + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + + await extension.awaitMessage("no-onBeforeScript:done"); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_scriptMetaData() { + function getTestCases(isUserScriptsRegister) { + return [ + // When scriptMetadata is not set (or undefined), it is treated as if it were null. + // In the API script, the metadata is then expected to be null. + isUserScriptsRegister ? undefined : null, + + // Falsey + null, + "", + false, + 0, + + // Truthy + true, + 1, + "non-empty string", + + // Objects + ["some array with value"], + { "some object": "with value" }, + ]; + } + + async function background(pageUrl) { + for (let scriptMetadata of getTestCases(true)) { + await browser.userScripts.register({ + js: [{ file: "userscript.js" }], + runAt: "document_end", + allFrames: true, + matches: ["http://localhost/*/file_sample.html"], + scriptMetadata, + }); + } + + let f = document.createElement("iframe"); + f.src = pageUrl; + document.body.append(f); + browser.test.sendMessage("background-page:done"); + } + + function apiScript() { + let testCases = getTestCases(false); + let i = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + checkMetadata() { + let expectation = testCases[i]; + let metadata = script.metadata; + if (typeof expectation === "object" && expectation !== null) { + // Non-primitive values cannot be compared with assertEq, + // so serialize both and just verify that they are equal. + expectation = JSON.stringify(expectation); + metadata = JSON.stringify(script.metadata); + } + + browser.test.assertEq( + expectation, + metadata, + `Expected metadata at call ${i}` + ); + if (++i === testCases.length) { + browser.test.sendMessage("apiscript:done"); + } + }, + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `${getTestCases};(${background})("${BASE_URL}/file_sample.html")`, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { + api_script: "apiscript.js", + }, + }, + files: { + "apiscript.js": `${getTestCases};(${apiScript})()`, + "userscript.js": "checkMetadata();", + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-page:done"); + await extension.awaitMessage("apiscript:done"); + + await extension.unload(); +}); + +add_task(async function test_userScriptOptions_js_property_required() { + function background() { + const userScriptOptions = { + runAt: "document_start", + matches: ["http://*/*/file_sample.html"], + }; + + browser.test.assertThrows( + () => browser.userScripts.register(userScriptOptions), + /Type error for parameter userScriptOptions \(Property \"js\" is required\)/, + "Got the expected error from userScripts.register when js property is missing" + ); + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: {}, + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js new file mode 100644 index 0000000000..72e8a51c7f --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_exports.js @@ -0,0 +1,1108 @@ +"use strict"; + +const { createAppInfo } = AddonTestUtils; + +AddonTestUtils.init(this); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49"); + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +// A small utility function used to test the expected behaviors of the userScripts API method +// wrapper. +async function test_userScript_APIMethod({ + apiScript, + userScript, + userScriptMetadata, + testFn, + runtimeMessageListener, +}) { + async function backgroundScript( + userScriptFn, + scriptMetadata, + messageListener + ) { + await browser.userScripts.register({ + js: [ + { + code: `(${userScriptFn})();`, + }, + ], + runAt: "document_end", + matches: ["http://localhost/*/file_sample.html"], + scriptMetadata, + }); + + if (messageListener) { + browser.runtime.onMessage.addListener(messageListener); + } + + browser.test.sendMessage("background-ready"); + } + + function notifyFinish(failureReason) { + browser.test.assertEq( + undefined, + failureReason, + "should be completed without errors" + ); + browser.test.sendMessage("test_userScript_APIMethod:done"); + } + + function assertTrue(val, message) { + browser.test.assertTrue(val, message); + if (!val) { + browser.test.sendMessage("test_userScript_APIMethod:done"); + throw message; + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://localhost/*/file_sample.html"], + user_scripts: { + api_script: "api-script.js", + }, + }, + // Defines a background script that receives all the needed test parameters. + background: ` + const metadata = ${JSON.stringify(userScriptMetadata)}; + (${backgroundScript})(${userScript}, metadata, ${runtimeMessageListener}) + `, + files: { + "api-script.js": `(${apiScript})({ + assertTrue: ${assertTrue}, + notifyFinish: ${notifyFinish} + })`, + }, + }); + + // Load a page in a content process, register the user script and then load a + // new page in the existing content process. + let url = `${BASE_URL}/file_sample.html`; + let contentPage = await ExtensionTestUtils.loadContentPage(`about:blank`); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + await contentPage.loadURL(url); + + // Run any additional test-specific assertions. + if (testFn) { + await testFn({ extension, contentPage, url }); + } + + await extension.awaitMessage("test_userScript_APIMethod:done"); + + await extension.unload(); + await contentPage.close(); +} + +add_task(async function test_apiScript_exports_simple_sync_method() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + const scriptMetadata = script.metadata; + + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod( + stringParam, + numberParam, + boolParam, + nullParam, + undefinedParam, + arrayParam + ) { + browser.test.assertEq( + "test-user-script-exported-apis", + scriptMetadata.name, + "Got the expected value for a string scriptMetadata property" + ); + browser.test.assertEq( + null, + scriptMetadata.nullProperty, + "Got the expected value for a null scriptMetadata property" + ); + browser.test.assertTrue( + scriptMetadata.arrayProperty && + scriptMetadata.arrayProperty.length === 1 && + scriptMetadata.arrayProperty[0] === "el1", + "Got the expected value for an array scriptMetadata property" + ); + browser.test.assertTrue( + scriptMetadata.objectProperty && + scriptMetadata.objectProperty.nestedProp === "nestedValue", + "Got the expected value for an object scriptMetadata property" + ); + + browser.test.assertEq( + "param1", + stringParam, + "Got the expected string parameter value" + ); + browser.test.assertEq( + 123, + numberParam, + "Got the expected number parameter value" + ); + browser.test.assertEq( + true, + boolParam, + "Got the expected boolean parameter value" + ); + browser.test.assertEq( + null, + nullParam, + "Got the expected null parameter value" + ); + browser.test.assertEq( + undefined, + undefinedParam, + "Got the expected undefined parameter value" + ); + + browser.test.assertEq( + 3, + arrayParam.length, + "Got the expected length on the array param" + ); + browser.test.assertTrue( + arrayParam.includes(1), + "Got the expected result when calling arrayParam.includes" + ); + + return "returned_value"; + }, + }); + }); + } + + function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + // Redefine the includes method on the Array prototype, to explicitly verify that the method + // redefined in the userScript is not used when accessing arrayParam.includes from the API script. + // eslint-disable-next-line no-extend-native + Array.prototype.includes = () => { + throw new Error("Unexpected prototype leakage"); + }; + const arrayParam = new Array(1, 2, 3); // eslint-disable-line no-array-constructor + const result = testAPIMethod( + "param1", + 123, + true, + null, + undefined, + arrayParam + ); + + assertTrue( + result === "returned_value", + `userScript got an unexpected result value: ${result}` + ); + + notifyFinish(); + } + + const userScriptMetadata = { + name: "test-user-script-exported-apis", + arrayProperty: ["el1"], + objectProperty: { nestedProp: "nestedValue" }, + nullProperty: null, + }; + + await test_userScript_APIMethod({ + userScript, + apiScript, + userScriptMetadata, + }); +}); + +add_task(async function test_apiScript_async_method() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(param, cb, cb2, objWithCb) { + browser.test.assertEq( + "function", + typeof cb, + "Got a callback function parameter" + ); + browser.test.assertTrue( + cb === cb2, + "Got the same cloned function for the same function parameter" + ); + + browser.runtime.sendMessage(param).then(bgPageRes => { + const cbResult = cb(script.export(bgPageRes)); + browser.test.sendMessage("user-script-callback-return", cbResult); + }); + + return "resolved_value"; + }, + }); + }); + } + + async function userScript() { + // Redefine Promise to verify that it doesn't break the WebExtensions internals + // that are going to use them. + const { Promise } = this; + Promise.resolve = function() { + throw new Error("Promise.resolve poisoning"); + }; + this.Promise = function() { + throw new Error("Promise constructor poisoning"); + }; + + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const cb = cbParam => { + return `callback param: ${JSON.stringify(cbParam)}`; + }; + const cb2 = cb; + const asyncAPIResult = await testAPIMethod("param3", cb, cb2); + + assertTrue( + asyncAPIResult === "resolved_value", + `userScript got an unexpected resolved value: ${asyncAPIResult}` + ); + + notifyFinish(); + } + + async function runtimeMessageListener(param) { + if (param !== "param3") { + browser.test.fail(`Got an unexpected message: ${param}`); + } + + return { bgPageReply: true }; + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + runtimeMessageListener, + async testFn({ extension }) { + const res = await extension.awaitMessage("user-script-callback-return"); + equal( + res, + `callback param: ${JSON.stringify({ bgPageReply: true })}`, + "Got the expected userScript callback return value" + ); + }, + }); +}); + +add_task(async function test_apiScript_method_with_webpage_objects_params() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(windowParam, documentParam) { + browser.test.assertEq( + window, + windowParam, + "Got a reference to the native window as first param" + ); + browser.test.assertEq( + window.document, + documentParam, + "Got a reference to the native document as second param" + ); + + // Return an uncloneable webpage object, which checks that if the returned object is from a principal + // that is subsumed by the userScript sandbox principal, it is returned without being cloned. + return windowParam; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const result = testAPIMethod(window, document); + + // We expect the returned value to be the uncloneable window object. + assertTrue( + result === window, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_got_param_with_methods() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + const scriptGlobal = script.global; + const ScriptFunction = scriptGlobal.Function; + + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(objWithMethods) { + browser.test.assertEq( + "objPropertyValue", + objWithMethods && objWithMethods.objProperty, + "Got the expected property on the object passed as a parameter" + ); + browser.test.assertEq( + undefined, + typeof objWithMethods && objWithMethods.objMethod, + "XrayWrapper should deny access to a callable property" + ); + + browser.test.assertTrue( + objWithMethods && + objWithMethods.wrappedJSObject && + objWithMethods.wrappedJSObject.objMethod instanceof + ScriptFunction.wrappedJSObject, + "The callable property is accessible on the wrappedJSObject" + ); + + browser.test.assertEq( + "objMethodResult: p1", + objWithMethods && + objWithMethods.wrappedJSObject && + objWithMethods.wrappedJSObject.objMethod("p1"), + "Got the expected result when calling the method on the wrappedJSObject" + ); + return true; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let result = testAPIMethod({ + objProperty: "objPropertyValue", + objMethod(param) { + return `objMethodResult: ${param}`; + }, + }); + + assertTrue( + result === true, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_method_throws_errors() { + function apiScript({ notifyFinish }) { + let proxyTrapsCount = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + const scriptGlobals = { + Error: script.global.Error, + TypeError: script.global.TypeError, + Proxy: script.global.Proxy, + }; + + script.defineGlobals({ + notifyFinish, + testAPIMethod(errorTestName, returnRejectedPromise) { + let err; + + switch (errorTestName) { + case "apiScriptError": + err = new Error(`${errorTestName} message`); + break; + case "apiScriptThrowsPlainString": + err = `${errorTestName} message`; + break; + case "apiScriptThrowsNull": + err = null; + break; + case "userScriptError": + err = new scriptGlobals.Error(`${errorTestName} message`); + break; + case "userScriptTypeError": + err = new scriptGlobals.TypeError(`${errorTestName} message`); + break; + case "userScriptProxyObject": + let proxyTarget = script.export({ + name: "ProxyObject", + message: "ProxyObject message", + }); + let proxyHandlers = script.export({ + get(target, prop) { + proxyTrapsCount++; + switch (prop) { + case "name": + return "ProxyObjectGetName"; + case "message": + return "ProxyObjectGetMessage"; + } + return undefined; + }, + getPrototypeOf() { + proxyTrapsCount++; + return scriptGlobals.TypeError; + }, + }); + err = new scriptGlobals.Proxy(proxyTarget, proxyHandlers); + break; + default: + browser.test.fail(`Unknown ${errorTestName} error testname`); + return undefined; + } + + if (returnRejectedPromise) { + return Promise.reject(err); + } + + throw err; + }, + assertNoProxyTrapTriggered() { + browser.test.assertEq( + 0, + proxyTrapsCount, + "Proxy traps should not be triggered" + ); + }, + resetProxyTrapCounter() { + proxyTrapsCount = 0; + }, + sendResults(results) { + browser.test.sendMessage("test-results", results); + }, + }); + }); + } + + async function userScript() { + const { + assertNoProxyTrapTriggered, + notifyFinish, + resetProxyTrapCounter, + sendResults, + testAPIMethod, + } = this; + + let apiThrowResults = {}; + let apiThrowTestCases = [ + "apiScriptError", + "apiScriptThrowsPlainString", + "apiScriptThrowsNull", + "userScriptError", + "userScriptTypeError", + "userScriptProxyObject", + ]; + for (let errorTestName of apiThrowTestCases) { + try { + testAPIMethod(errorTestName); + } catch (err) { + // We expect that no proxy traps have been triggered by the WebExtensions internals. + if (errorTestName === "userScriptProxyObject") { + assertNoProxyTrapTriggered(); + } + + if (err instanceof Error) { + apiThrowResults[errorTestName] = { + name: err.name, + message: err.message, + }; + } else { + apiThrowResults[errorTestName] = { + name: err && err.name, + message: err && err.message, + typeOf: typeof err, + value: err, + }; + } + } + } + + sendResults(apiThrowResults); + + resetProxyTrapCounter(); + + let apiRejectsResults = {}; + for (let errorTestName of apiThrowTestCases) { + try { + await testAPIMethod(errorTestName, true); + } catch (err) { + // We expect that no proxy traps have been triggered by the WebExtensions internals. + if (errorTestName === "userScriptProxyObject") { + assertNoProxyTrapTriggered(); + } + + if (err instanceof Error) { + apiRejectsResults[errorTestName] = { + name: err.name, + message: err.message, + }; + } else { + apiRejectsResults[errorTestName] = { + name: err && err.name, + message: err && err.message, + typeOf: typeof err, + value: err, + }; + } + } + } + + sendResults(apiRejectsResults); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + async testFn({ extension }) { + const expectedResults = { + // Any error not explicitly raised as a userScript objects or error instance is + // expected to be turned into a generic error message. + apiScriptError: { + name: "Error", + message: "An unexpected apiScript error occurred", + }, + + // When the api script throws a primitive value, we expect to receive it unmodified on + // the userScript side. + apiScriptThrowsPlainString: { + typeOf: "string", + value: "apiScriptThrowsPlainString message", + name: undefined, + message: undefined, + }, + apiScriptThrowsNull: { + typeOf: "object", + value: null, + name: undefined, + message: undefined, + }, + + // Error messages that the apiScript has explicitly created as userScript's Error + // global instances are expected to be passing through unmodified. + userScriptError: { name: "Error", message: "userScriptError message" }, + userScriptTypeError: { + name: "TypeError", + message: "userScriptTypeError message", + }, + + // Error raised from the apiScript as userScript proxy objects are expected to + // be passing through unmodified. + userScriptProxyObject: { + typeOf: "object", + name: "ProxyObjectGetName", + message: "ProxyObjectGetMessage", + }, + }; + + info( + "Checking results from errors raised from an apiScript exported function" + ); + + const apiThrowResults = await extension.awaitMessage("test-results"); + + for (let [key, expected] of Object.entries(expectedResults)) { + Assert.deepEqual( + apiThrowResults[key], + expected, + `Got the expected error object for test case "${key}"` + ); + } + + Assert.deepEqual( + Object.keys(expectedResults).sort(), + Object.keys(apiThrowResults).sort(), + "the expected and actual test case names matches" + ); + + info( + "Checking expected results from errors raised from an apiScript exported function" + ); + + // Verify expected results from rejected promises returned from an apiScript exported function. + const apiThrowRejections = await extension.awaitMessage("test-results"); + + for (let [key, expected] of Object.entries(expectedResults)) { + Assert.deepEqual( + apiThrowRejections[key], + expected, + `Got the expected rejected object for test case "${key}"` + ); + } + + Assert.deepEqual( + Object.keys(expectedResults).sort(), + Object.keys(apiThrowRejections).sort(), + "the expected and actual test case names matches" + ); + }, + }); +}); + +add_task( + async function test_apiScript_method_ensure_xraywrapped_proxy_in_params() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(...args) { + // Proxies are opaque when wrapped in Xrays, and the proto of an opaque object + // is supposed to be Object.prototype. + browser.test.assertEq( + script.global.Object.prototype, + Object.getPrototypeOf(args[0]), + "Calling getPrototypeOf on the XrayWrapped proxy object doesn't run the proxy trap" + ); + + browser.test.assertTrue( + Array.isArray(args[0]), + "Got an array object for the XrayWrapped proxy object param" + ); + browser.test.assertEq( + undefined, + args[0].length, + "XrayWrappers deny access to the length property" + ); + browser.test.assertEq( + undefined, + args[0][0], + "Got the expected item in the array object" + ); + return true; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let proxy = new Proxy(["expectedArrayValue"], { + getPrototypeOf() { + throw new Error("Proxy's getPrototypeOf trap"); + }, + get(target, prop, receiver) { + throw new Error("Proxy's get trap"); + }, + }); + + let result = testAPIMethod(proxy); + + assertTrue( + result, + `userScript got an unexpected returned value: ${result}` + ); + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_return_proxy_object() { + function apiScript(sharedTestAPIMethods) { + let proxyTrapsCount = 0; + let scriptTrapsCount = 0; + + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodError() { + return new Proxy(["expectedArrayValue"], { + getPrototypeOf(target) { + proxyTrapsCount++; + return Object.getPrototypeOf(target); + }, + }); + }, + testAPIMethodOk() { + return new script.global.Proxy( + script.export(["expectedArrayValue"]), + script.export({ + getPrototypeOf(target) { + scriptTrapsCount++; + return script.global.Object.getPrototypeOf(target); + }, + }) + ); + }, + assertNoProxyTrapTriggered() { + browser.test.assertEq( + 0, + proxyTrapsCount, + "Proxy traps should not be triggered" + ); + }, + assertScriptProxyTrapsCount(expected) { + browser.test.assertEq( + expected, + scriptTrapsCount, + "Script Proxy traps should have been triggered" + ); + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + assertNoProxyTrapTriggered, + assertScriptProxyTrapsCount, + notifyFinish, + testAPIMethodError, + testAPIMethodOk, + } = this; + + let error; + try { + let result = testAPIMethodError(); + notifyFinish( + `Unexpected returned value while expecting error: ${result}` + ); + return; + } catch (err) { + error = err; + } + + assertTrue( + error && + error.message.includes("Return value not accessible to the userScript"), + `Got an unexpected error message: ${error}` + ); + + error = undefined; + try { + let result = testAPIMethodOk(); + assertScriptProxyTrapsCount(0); + if (!(result instanceof Array)) { + notifyFinish(`Got an unexpected result: ${result}`); + return; + } + assertScriptProxyTrapsCount(1); + } catch (err) { + error = err; + } + + assertTrue(!error, `Got an unexpected error: ${error}`); + + assertNoProxyTrapTriggered(); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task(async function test_apiScript_returns_functions() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIReturnsFunction() { + // Return a function with provides the same kind of behavior + // of the API methods exported as globals. + return script.export(() => window); + }, + testAPIReturnsObjWithMethod() { + return script.export({ + getWindow() { + return window; + }, + }); + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIReturnsFunction, + testAPIReturnsObjWithMethod, + } = this; + + let resultFn = testAPIReturnsFunction(); + assertTrue( + typeof resultFn === "function", + `userScript got an unexpected returned value: ${typeof resultFn}` + ); + + let fnRes = resultFn(); + assertTrue( + fnRes === window, + `Got an unexpected value from the returned function: ${fnRes}` + ); + + let resultObj = testAPIReturnsObjWithMethod(); + let actualTypeof = resultObj && typeof resultObj.getWindow; + assertTrue( + actualTypeof === "function", + `Returned object does not have the expected getWindow method: ${actualTypeof}` + ); + + let methodRes = resultObj.getWindow(); + assertTrue( + methodRes === window, + `Got an unexpected value from the returned method: ${methodRes}` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task( + async function test_apiScript_method_clone_non_subsumed_returned_values() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodReturnOk() { + return script.export({ + objKey1: { + nestedProp: "nestedvalue", + }, + window, + }); + }, + testAPIMethodExplicitlyClonedError() { + let result = script.export({ apiScopeObject: undefined }); + + browser.test.assertThrows( + () => { + result.apiScopeObject = { disallowedProp: "disallowedValue" }; + }, + /Not allowed to define cross-origin object as property on .* XrayWrapper/, + "Assigning a property to a xRayWrapper is expected to throw" + ); + + // Let the exception to be raised, so that we check that the actual underlying + // error message is not leaking in the userScript (replaced by the generic + // "An unexpected apiScript error occurred" error message). + result.apiScopeObject = { disallowedProp: "disallowedValue" }; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethodReturnOk, + testAPIMethodExplicitlyClonedError, + } = this; + + let result = testAPIMethodReturnOk(); + + assertTrue( + result && + "objKey1" in result && + result.objKey1.nestedProp === "nestedvalue", + `userScript got an unexpected returned value: ${result}` + ); + + assertTrue( + result.window === window, + `userScript should have access to the window property: ${result.window}` + ); + + let error; + try { + result = testAPIMethodExplicitlyClonedError(); + notifyFinish( + `Unexpected returned value while expecting error: ${result}` + ); + return; + } catch (err) { + error = err; + } + + // We expect the generic "unexpected apiScript error occurred" to be raised to the + // userScript code. + assertTrue( + error && + error.message.includes("An unexpected apiScript error occurred"), + `Got an unexpected error message: ${error}` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_export_primitive_types() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod(typeToExport) { + switch (typeToExport) { + case "boolean": + return script.export(true); + case "number": + return script.export(123); + case "string": + return script.export("a string"); + case "symbol": + return script.export(Symbol("a symbol")); + } + return undefined; + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + let v = testAPIMethod("boolean"); + assertTrue(v === true, `Should export a boolean`); + + v = testAPIMethod("number"); + assertTrue(v === 123, `Should export a number`); + + v = testAPIMethod("string"); + assertTrue(v === "a string", `Should export a string`); + + v = testAPIMethod("symbol"); + assertTrue(typeof v === "symbol", `Should export a symbol`); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); + +add_task( + async function test_apiScript_method_avoid_unnecessary_params_cloning() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethodReturnsParam(param) { + return param; + }, + testAPIMethodReturnsUnwrappedParam(param) { + return param.wrappedJSObject; + }, + }); + }); + } + + async function userScript() { + const { + assertTrue, + notifyFinish, + testAPIMethodReturnsParam, + testAPIMethodReturnsUnwrappedParam, + } = this; + + let obj = {}; + + let result = testAPIMethodReturnsParam(obj); + + assertTrue( + result === obj, + `Expect returned value to be strictly equal to the API method parameter` + ); + + result = testAPIMethodReturnsUnwrappedParam(obj); + + assertTrue( + result === obj, + `Expect returned value to be strictly equal to the unwrapped API method parameter` + ); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); + } +); + +add_task(async function test_apiScript_method_export_sparse_arrays() { + function apiScript(sharedTestAPIMethods) { + browser.userScripts.onBeforeScript.addListener(script => { + script.defineGlobals({ + ...sharedTestAPIMethods, + testAPIMethod() { + const sparseArray = []; + sparseArray[3] = "third-element"; + sparseArray[5] = "fifth-element"; + return script.export(sparseArray); + }, + }); + }); + } + + async function userScript() { + const { assertTrue, notifyFinish, testAPIMethod } = this; + + const result = testAPIMethod(window, document); + + // We expect the returned value to be the uncloneable window object. + assertTrue( + result && result.length === 6, + `the returned value should be an array of the expected length: ${result}` + ); + assertTrue( + result[3] === "third-element", + `the third array element should have the expected value: ${result[3]}` + ); + assertTrue( + result[5] === "fifth-element", + `the fifth array element should have the expected value: ${result[5]}` + ); + assertTrue( + result[0] === undefined, + `the first array element should have the expected value: ${result[0]}` + ); + assertTrue(!("0" in result), "Holey array should still be holey"); + + notifyFinish(); + } + + await test_userScript_APIMethod({ + userScript, + apiScript, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js new file mode 100644 index 0000000000..08d61d1e85 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_userScripts_telemetry.js @@ -0,0 +1,175 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_USER_SCRIPT_INJECTION_MS"; +const HISTOGRAM_KEYED = "WEBEXT_USER_SCRIPT_INJECTION_MS_BY_ADDONID"; + +const server = createHttpServer(); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`; + +add_task(async function test_userScripts_telemetry() { + function apiScript() { + browser.userScripts.onBeforeScript.addListener(userScript => { + const scriptMetadata = userScript.metadata; + + userScript.defineGlobals({ + US_test_sendMessage(msg, data) { + browser.test.sendMessage(msg, { data, scriptMetadata }); + }, + }); + }); + } + + async function background() { + const code = ` + US_test_sendMessage("userScript-run", {location: window.location.href}); + `; + await browser.userScripts.register({ + js: [{ code }], + matches: ["http://*/*/file_sample.html"], + runAt: "document_end", + scriptMetadata: { + name: "test-user-script-telemetry", + }, + }); + + browser.test.sendMessage("userScript-registered"); + } + + Services.prefs.setBoolPref( + "toolkit.telemetry.testing.overrideProductsCheck", + true + ); + + let testExtensionDef = { + manifest: { + permissions: ["http://*/*/file_sample.html"], + user_scripts: { + api_script: "api-script.js", + }, + }, + background, + files: { + "api-script.js": apiScript, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(testExtensionDef); + let extension2 = ExtensionTestUtils.loadExtension(testExtensionDef); + let contentPage = await ExtensionTestUtils.loadContentPage("about:blank"); + + clearHistograms(); + + let process = IS_OOP ? "content" : "parent"; + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + + await extension.startup(); + await extension.awaitMessage("userScript-registered"); + + let extensionId = extension.extension.id; + + ok( + !(HISTOGRAM in getSnapshots(process)), + `No data recorded for histogram after startup: ${HISTOGRAM}.` + ); + ok( + !(HISTOGRAM_KEYED in getKeyedSnapshots(process)), + `No data recorded for keyed histogram: ${HISTOGRAM_KEYED}.` + ); + + let url = `${BASE_URL}/file_sample.html`; + contentPage.loadURL(url); + const res = await extension.awaitMessage("userScript-run"); + Assert.deepEqual( + res, + { + data: { location: url }, + scriptMetadata: { name: "test-user-script-telemetry" }, + }, + "The userScript has been executed on the content page as expected" + ); + + await promiseTelemetryRecorded(HISTOGRAM, process, 1); + await promiseKeyedTelemetryRecorded(HISTOGRAM_KEYED, process, extensionId, 1); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + + await contentPage.close(); + await extension.unload(); + + await extension2.startup(); + await extension2.awaitMessage("userScript-registered"); + let extensionId2 = extension2.extension.id; + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 1, + `No data recorded for histogram after startup: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram after extension2 startup: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + ok( + !(extensionId2 in getKeyedSnapshots(process)[HISTOGRAM_KEYED]), + `No data recorded for histogram after startup: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + + contentPage = await ExtensionTestUtils.loadContentPage(url); + const res2 = await extension2.awaitMessage("userScript-run"); + Assert.deepEqual( + res2, + { + data: { location: url }, + scriptMetadata: { name: "test-user-script-telemetry" }, + }, + "The userScript has been executed on the content page as expected" + ); + + await promiseTelemetryRecorded(HISTOGRAM, process, 2); + await promiseKeyedTelemetryRecorded( + HISTOGRAM_KEYED, + process, + extensionId2, + 1 + ); + + equal( + valueSum(getSnapshots(process)[HISTOGRAM].values), + 2, + `Data recorded for histogram: ${HISTOGRAM}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId].values), + 1, + `No new data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId}.` + ); + equal( + valueSum(getKeyedSnapshots(process)[HISTOGRAM_KEYED][extensionId2].values), + 1, + `Data recorded for histogram: ${HISTOGRAM_KEYED} with key ${extensionId2}.` + ); + + await contentPage.close(); + await extension2.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js new file mode 100644 index 0000000000..c616d162a5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_auth.js @@ -0,0 +1,425 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; + +// Save seen realms for cache checking. +let realms = new Set([]); + +server.registerPathHandler("/authenticate.sjs", (request, response) => { + let url = new URL(`${BASE_URL}${request.path}?${request.queryString}`); + let realm = url.searchParams.get("realm") || "mochitest"; + let proxy_realm = url.searchParams.get("proxy_realm"); + + function checkAuthorization(authorization) { + let expected_user = url.searchParams.get("user"); + if (!expected_user) { + return true; + } + let expected_pass = url.searchParams.get("pass"); + let actual_user, actual_pass; + let authHeader = request.getHeader("Authorization"); + let match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + let userpass = atob(match[1]); // no atob() :-( + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + actual_user = match[1]; + actual_pass = match[2]; + return expected_user === actual_user && expected_pass === actual_pass; + } + + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + if (proxy_realm && !request.hasHeader("Proxy-Authorization")) { + // We're not testing anything that requires checking the proxy auth user/password. + response.setStatusLine("1.0", 407, "Proxy authentication required"); + response.setHeader( + "Proxy-Authenticate", + `basic realm="${proxy_realm}"`, + true + ); + response.write("proxy auth required"); + } else if ( + !( + realms.has(realm) && + request.hasHeader("Authorization") && + checkAuthorization() + ) + ) { + realms.add(realm); + response.setStatusLine(request.httpVersion, 401, "Authentication required"); + response.setHeader("WWW-Authenticate", `basic realm="${realm}"`, true); + response.write("auth required"); + } else { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok, got authorization"); + } +}); + +function getExtension(bgConfig) { + function background(config) { + let path = config.path; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log( + `onBeforeRequest called with ${details.requestId} ${details.url}` + ); + browser.test.sendMessage("onBeforeRequest"); + return ( + config.onBeforeRequest.hasOwnProperty("result") && + config.onBeforeRequest.result + ); + }, + { urls: [path] }, + config.onBeforeRequest.hasOwnProperty("extra") + ? config.onBeforeRequest.extra + : [] + ); + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log( + `onAuthRequired called with ${details.requestId} ${details.url}` + ); + browser.test.assertEq( + config.realm, + details.realm, + "providing www authorization" + ); + browser.test.sendMessage("onAuthRequired"); + return ( + config.onAuthRequired.hasOwnProperty("result") && + config.onAuthRequired.result + ); + }, + { urls: [path] }, + config.onAuthRequired.hasOwnProperty("extra") + ? config.onAuthRequired.extra + : [] + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log( + `onCompleted called with ${details.requestId} ${details.url}` + ); + browser.test.sendMessage("onCompleted"); + }, + { urls: [path] } + ); + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log( + `onErrorOccurred called with ${JSON.stringify(details)}` + ); + browser.test.sendMessage("onErrorOccurred"); + }, + { urls: [path] } + ); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", bgConfig.path], + }, + background: `(${background})(${JSON.stringify(bgConfig)})`, + }); +} + +add_task(async function test_webRequest_auth() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let extension = getExtension(config); + await extension.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onCompleted"), + ]); + }), + ]); + await contentPage.close(); + + // Second time around to test cached credentials + contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onCompleted"), + ]); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_webRequest_auth_cancelled() { + // Test that any auth listener can cancel. + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let ex1 = getExtension(config); + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired"), + ex1.awaitMessage("onErrorOccurred"), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired"), + ex2.awaitMessage("onErrorOccurred"), + ]); + + await contentPage.close(); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_auth_nonblocking() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + result: { + authCredentials: { + username: "testuser", + password: "testpass", + }, + }, + }, + }; + + let ex1 = getExtension(config); + // non-blocking ext tries to cancel but cannot. + delete config.onBeforeRequest.extra; + delete config.onAuthRequired.extra; + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onCompleted"), + ]); + }), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onCompleted"), + ]); + }), + ]); + + await contentPage.close(); + Services.obs.notifyObservers(null, "net:clear-active-logins"); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_auth_blocking_noreturn() { + // The first listener is blocking but doesn't return anything. The second + // listener cancels the request. + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + }, + }; + + let ex1 = getExtension(config); + config.onAuthRequired.result = { cancel: true }; + let ex2 = getExtension(config); + await ex1.startup(); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired"), + ex1.awaitMessage("onErrorOccurred"), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired"), + ex2.awaitMessage("onErrorOccurred"), + ]); + + await contentPage.close(); + await ex1.unload(); + await ex2.unload(); +}); + +add_task(async function test_webRequest_duelingAuth() { + let config = { + path: `${BASE_URL}/*`, + realm: `webRequest_auth${Math.random()}`, + onBeforeRequest: { + extra: ["blocking"], + }, + onAuthRequired: { + extra: ["blocking"], + }, + }; + let exNone = getExtension(config); + await exNone.startup(); + + let authCredentials = { + username: `testuser_da1${Math.random()}`, + password: `testpass_da1${Math.random()}`, + }; + config.onAuthRequired.result = { authCredentials }; + let ex1 = getExtension(config); + await ex1.startup(); + + config.onAuthRequired.result = {}; + let exEmpty = getExtension(config); + await exEmpty.startup(); + + config.onAuthRequired.result = { + authCredentials: { + username: `testuser_da2${Math.random()}`, + password: `testpass_da2${Math.random()}`, + }, + }; + let ex2 = getExtension(config); + await ex2.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=${config.realm}&user=${authCredentials.username}&pass=${authCredentials.password}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + await Promise.all([ + exNone.awaitMessage("onBeforeRequest"), + exNone.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + exNone.awaitMessage("onBeforeRequest"), + exNone.awaitMessage("onCompleted"), + ]); + }), + exEmpty.awaitMessage("onBeforeRequest"), + exEmpty.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + exEmpty.awaitMessage("onBeforeRequest"), + exEmpty.awaitMessage("onCompleted"), + ]); + }), + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex1.awaitMessage("onBeforeRequest"), + ex1.awaitMessage("onCompleted"), + ]); + }), + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onAuthRequired").then(() => { + return Promise.all([ + ex2.awaitMessage("onBeforeRequest"), + ex2.awaitMessage("onCompleted"), + ]); + }), + ]); + + await Promise.all([ + await contentPage.close(), + exNone.unload(), + exEmpty.unload(), + ex1.unload(), + ex2.unload(), + ]); +}); + +add_task(async function test_webRequest_auth_proxy() { + function background(permissionPath) { + let proxyOk = false; + browser.webRequest.onAuthRequired.addListener( + details => { + browser.test.log( + `handlingExt onAuthRequired called with ${details.requestId} ${details.url}` + ); + if (details.isProxy) { + browser.test.succeed("providing proxy authorization"); + proxyOk = true; + return { authCredentials: { username: "puser", password: "ppass" } }; + } + browser.test.assertTrue( + proxyOk, + "providing www authorization after proxy auth" + ); + browser.test.sendMessage("done"); + return { authCredentials: { username: "auser", password: "apass" } }; + }, + { urls: [permissionPath] }, + ["blocking"] + ); + } + + let handlingExt = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/*`], + }, + background: `(${background})("${BASE_URL}/*")`, + }); + + await handlingExt.startup(); + + let requestUrl = `${BASE_URL}/authenticate.sjs?realm=webRequest_auth${Math.random()}&proxy_realm=proxy_auth${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(requestUrl); + + await handlingExt.awaitMessage("done"); + await contentPage.close(); + await handlingExt.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js new file mode 100644 index 0000000000..c18c75a580 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cached.js @@ -0,0 +1,311 @@ +"use strict"; + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/data/file_sample.html"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); +server.registerPathHandler("/status", (request, response) => { + let IfNoneMatch = request.hasHeader("If-None-Match") + ? request.getHeader("If-None-Match") + : ""; + + switch (IfNoneMatch) { + case "1234567890": + response.setStatusLine("1.1", 304, "Not Modified"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Etag", "1234567890", false); + break; + case "": + response.setStatusLine("1.1", 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Etag", "1234567890", false); + response.write("ok"); + break; + default: + throw new Error(`Unexpected If-None-Match: ${IfNoneMatch}`); + } +}); + +// This test initialises a cache entry with a CSP header, then +// loads the cached entry and replaces the CSP header with +// a new one. We test in onResponseStarted that the header +// is what we expect. +add_task(async function test_replaceResponseHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + function replaceHeader(headers, newHeader) { + headers = headers.filter(header => header.name !== newHeader.name); + headers.push(newHeader); + return headers; + } + let testHeaders = [ + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src 'none'", + }, + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src https:", + }, + ]; + browser.webRequest.onHeadersReceived.addListener( + details => { + if (!details.fromCache) { + // Add a CSP header on the initial request + details.responseHeaders.push(testHeaders[0]); + return { + responseHeaders: details.responseHeaders, + }; + } + // Test that the header added during the initial request is + // now in the cached response. + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == "Content-Security-Policy"; + }); + browser.test.assertEq( + header[0].value, + testHeaders[0].value, + "pre-cached header exists" + ); + // Replace the cached value so we can test overriding the header that was cached. + return { + responseHeaders: replaceHeader( + details.responseHeaders, + testHeaders[1] + ), + }; + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onResponseStarted.addListener( + details => { + let needle = details.fromCache ? testHeaders[1] : testHeaders[0]; + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == needle.name && header.value == needle.value; + }); + browser.test.assertEq( + header.length, + 1, + "header exists with correct value" + ); + if (details.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await extension.awaitMessage("from-cache"); + + await extension.unload(); +}); + +// This test initialises a cache entry with a CSP header, then +// loads the cached entry and adds a second CSP header. We also +// test that the browser has the CSP entries we expect. +add_task(async function test_addCSPHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + let testHeaders = [ + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src 'none'", + }, + { + name: "Content-Security-Policy", + value: "object-src 'none'; script-src https:", + }, + ]; + browser.webRequest.onHeadersReceived.addListener( + details => { + if (!details.fromCache) { + details.responseHeaders.push(testHeaders[0]); + return { + responseHeaders: details.responseHeaders, + }; + } + browser.test.log("cached request received"); + details.responseHeaders.push(testHeaders[1]); + return { + responseHeaders: details.responseHeaders, + }; + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onCompleted.addListener( + details => { + let { name, value } = testHeaders[0]; + if (details.fromCache) { + value = `${value}, ${testHeaders[1].value}`; + } + let header = details.responseHeaders.filter(header => { + browser.test.log(`header ${header.name} = ${header.value}`); + return header.name == name && header.value == value; + }); + browser.test.assertEq( + header.length, + 1, + "header exists with correct value" + ); + if (details.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + let contentPage = await ExtensionTestUtils.loadContentPage(url); + equal(contentPage.browser.csp.policyCount, 1, "expected 1 policy"); + equal( + contentPage.browser.csp.getPolicy(0), + "object-src 'none'; script-src 'none'", + "expected policy" + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage(url); + equal(contentPage.browser.csp.policyCount, 2, "expected 2 policies"); + equal( + contentPage.browser.csp.getPolicy(0), + "object-src 'none'; script-src 'none'", + "expected first policy" + ); + equal( + contentPage.browser.csp.getPolicy(1), + "object-src 'none'; script-src https:", + "expected second policy" + ); + + await extension.awaitMessage("from-cache"); + await contentPage.close(); + + await extension.unload(); +}); + +// This test verifies that a content type changed during +// onHeadersReceived is cached. We initialize the cache, +// then load against a url that will specifically return +// a 304 status code. +add_task(async function test_addContentTypeHeaders() { + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders ${JSON.stringify(details)}\n`); + }, + { + urls: ["http://example.com/status*"], + }, + ["blocking", "requestHeaders"] + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.log(`onHeadersReceived ${JSON.stringify(details)}\n`); + if (!details.fromCache) { + browser.test.sendMessage("statusCode", details.statusCode); + const mime = details.responseHeaders.find(header => { + return header.value && header.name === "content-type"; + }); + if (mime) { + mime.value = "text/plain"; + } else { + details.responseHeaders.push({ + name: "content-type", + value: "text/plain", + }); + } + return { + responseHeaders: details.responseHeaders, + }; + } + }, + { + urls: ["http://example.com/status*"], + }, + ["blocking", "responseHeaders"] + ); + browser.webRequest.onCompleted.addListener( + details => { + browser.test.log(`onCompleted ${JSON.stringify(details)}\n`); + const mime = details.responseHeaders.find(header => { + return header.value && header.name === "content-type"; + }); + browser.test.sendMessage("contentType", mime.value); + }, + { + urls: ["http://example.com/status*"], + }, + ["responseHeaders"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/status` + ); + equal(await extension.awaitMessage("statusCode"), "200", "status OK"); + equal( + await extension.awaitMessage("contentType"), + "text/plain", + "plain text header" + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage(`${BASE_URL}/status`); + equal(await extension.awaitMessage("statusCode"), "304", "not modified"); + equal( + await extension.awaitMessage("contentType"), + "text/plain", + "plain text header" + ); + await contentPage.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js new file mode 100644 index 0000000000..de9ed535b3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_cancelWithReason.js @@ -0,0 +1,69 @@ +"use strict"; + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_cancel_with_reason() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + applications: { gecko: { id: "cancel@test" } }, + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { cancel: true }; + }, + { urls: ["*://*/*"] }, + ["blocking"] + ); + }, + }); + await ext.startup(); + + let data = await new Promise(resolve => { + let ssm = Services.scriptSecurityManager; + + let channel = NetUtil.newChannel({ + uri: `${gServerUrl}/dummy`, + loadingPrincipal: ssm.createContentPrincipalFromOrigin( + "http://localhost" + ), + contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + }); + + channel.asyncOpen({ + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onStartRequest(request) {}, + + onStopRequest(request, statusCode) { + let properties = request.QueryInterface(Ci.nsIPropertyBag); + let id = properties.getProperty("cancelledByExtension"); + let reason = request.loadInfo.requestBlockingReason; + resolve({ reason, id }); + }, + + onDataAvailable() {}, + }); + }); + + Assert.equal( + Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST, + data.reason, + "extension cancelled request" + ); + Assert.equal( + ext.id, + data.id, + "extension id attached to channel property bag" + ); + await ext.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js new file mode 100644 index 0000000000..75acb39000 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_download.js @@ -0,0 +1,43 @@ +"use strict"; + +// Test for Bug 1579911: Check that download requests created by the +// downloads.download API can be observed by extensions. +add_task(async function testDownload() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "downloads", + "https://example.com/*", + ], + }, + background: async function() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("request_intercepted"); + return { cancel: true }; + }, + { + urls: ["https://example.com/downloadtest"], + }, + ["blocking"] + ); + + browser.downloads.onChanged.addListener(delta => { + browser.test.assertEq(delta.state.current, "interrupted"); + browser.test.sendMessage("done"); + }); + + await browser.downloads.download({ + url: "https://example.com/downloadtest", + filename: "example.txt", + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("request_intercepted"); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js new file mode 100644 index 0000000000..27f4ff01e8 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterResponseData.js @@ -0,0 +1,523 @@ +"use strict"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +const HOSTS = new Set(["example.com", "example.org", "example.net"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/redirect", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", params.get("redirect_uri")); + response.setHeader("Access-Control-Allow-Origin", "*"); +}); + +server.registerPathHandler("/redirect301", (request, response) => { + let params = new URLSearchParams(request.queryString); + response.setStatusLine(request.httpVersion, 301, "Moved Permanently"); + response.setHeader("Location", params.get("redirect_uri")); + response.setHeader("Access-Control-Allow-Origin", "*"); +}); + +server.registerPathHandler("/script302.js", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Moved Temporarily"); + response.setHeader("Location", "http://example.com/script.js"); +}); + +server.registerPathHandler("/script.js", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/javascript"); + response.write(String.raw`console.log("HELLO!");`); +}); + +server.registerPathHandler("/302.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + response.write(String.raw` + <script type="application/javascript" src="http://example.com/script302.js"></script> + `); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.write("ok"); +}); + +server.registerPathHandler("/dummy.xhtml", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "application/xhtml+xml"); + response.write(String.raw`<?xml version="1.0"?> + <html xml:lang="en" xmlns="http://www.w3.org/1999/xhtml"> + <head/> + <body/> + </html> + `); +}); + +server.registerPathHandler("/lorem.html.gz", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + "Content-Type: text/html; charset=utf-8", + false + ); + response.setHeader("Content-Encoding", "gzip", false); + + let data = await OS.File.read(do_get_file("data/lorem.html.gz").path); + response.write(String.fromCharCode(...new Uint8Array(data))); + + response.finish(); +}); + +// Test re-encoding the data stream for bug 1590898. +add_task(async function test_stream_encoding_data() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + request => { + let filter = browser.webRequest.filterResponseData(request.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + }; + }, + { + urls: ["http://example.com/lorem.html.gz"], + types: ["main_frame"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/lorem.html.gz" + ); + + let content = await contentPage.spawn(null, () => { + return this.content.document.body.textContent; + }); + + ok( + content.includes("Lorem ipsum dolor sit amet"), + `expected content received` + ); + + await contentPage.close(); + await extension.unload(); +}); + +// Tests that the stream filter request is added to the document's load +// group, and blocks an XML document's load event until after the filter +// stops sending data. +add_task(async function test_xml_document_loadgroup_blocking() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + request => { + let filter = browser.webRequest.filterResponseData(request.requestId); + + let data = []; + filter.ondata = event => { + data.push(event.data); + }; + filter.onstop = async () => { + browser.test.sendMessage("phase", "original-onstop"); + + // Make a few trips through the event loop. + for (let i = 0; i < 10; i++) { + await new Promise(resolve => setTimeout(resolve, 0)); + } + + for (let buffer of data) { + filter.write(buffer); + } + browser.test.sendMessage("phase", "filter-onstop"); + filter.close(); + }; + }, + { + urls: ["http://example.com/dummy.xhtml"], + }, + ["blocking"] + ); + }, + + files: { + "content_script.js"() { + browser.test.sendMessage("phase", "content-script-start"); + window.addEventListener( + "DOMContentLoaded", + () => { + browser.test.sendMessage("phase", "content-script-domload"); + }, + { once: true } + ); + window.addEventListener( + "load", + () => { + browser.test.sendMessage("phase", "content-script-load"); + }, + { once: true } + ); + }, + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + + content_scripts: [ + { + matches: ["http://example.com/dummy.xhtml"], + run_at: "document_start", + js: ["content_script.js"], + }, + ], + }, + }); + + await extension.startup(); + + const EXPECTED = [ + "original-onstop", + "filter-onstop", + "content-script-start", + "content-script-domload", + "content-script-load", + ]; + + let done = new Promise(resolve => { + let phases = []; + extension.onMessage("phase", phase => { + phases.push(phase); + if (phases.length === EXPECTED.length) { + resolve(phases); + } + }); + }); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy.xhtml" + ); + + deepEqual(await done, EXPECTED, "Things happened, and in the right order"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_filter_content_fetch() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let pending = []; + + browser.webRequest.onBeforeRequest.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + let url = new URL(data.url); + + if (url.searchParams.get("redirect_uri")) { + pending.push( + new Promise(resolve => { + filter.onerror = resolve; + }).then(() => { + browser.test.assertEq( + "Channel redirected", + filter.error, + "Got correct error for redirected filter" + ); + }) + ); + } + + filter.onstart = () => { + filter.write(new TextEncoder().encode(data.url)); + }; + filter.ondata = event => { + let str = new TextDecoder().decode(event.data); + browser.test.assertEq( + "ok", + str, + `Got unfiltered data for ${data.url}` + ); + }; + filter.onstop = () => { + filter.close(); + }; + }, + { + urls: ["<all_urls>"], + }, + ["blocking"] + ); + + browser.test.onMessage.addListener(async msg => { + if (msg === "done") { + await Promise.all(pending); + browser.test.notifyPass("stream-filter"); + } + }); + }, + + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/", + "http://example.org/", + ], + }, + }); + + await extension.startup(); + + let results = [ + ["http://example.com/dummy", "http://example.com/dummy"], + ["http://example.org/dummy", "http://example.org/dummy"], + ["http://example.net/dummy", "ok"], + [ + "http://example.com/redirect?redirect_uri=http://example.com/dummy", + "http://example.com/dummy", + ], + [ + "http://example.com/redirect?redirect_uri=http://example.org/dummy", + "http://example.org/dummy", + ], + ["http://example.com/redirect?redirect_uri=http://example.net/dummy", "ok"], + [ + "http://example.net/redirect?redirect_uri=http://example.com/dummy", + "http://example.com/dummy", + ], + ].map(async ([url, expectedResponse]) => { + let text = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + equal(text, expectedResponse, `Expected response for ${url}`); + }); + + await Promise.all(results); + + extension.sendMessage("done"); + await extension.awaitFinish("stream-filter"); + await extension.unload(); +}); + +add_task(async function test_filter_301() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onHeadersReceived.addListener( + data => { + if (data.statusCode !== 200) { + return; + } + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = () => { + filter.close(); + browser.test.notifyPass("stream-filter"); + }; + filter.onerror = () => { + browser.test.fail(`unexpected ${filter.error}`); + }; + }, + { + urls: ["<all_urls>"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://example.com/", + "http://example.org/", + ], + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/redirect301?redirect_uri=http://example.org/dummy" + ); + + await extension.awaitFinish("stream-filter"); + + await contentPage.close(); + await extension.unload(); +}); + +add_task(async function test_filter_302() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + browser.test.sendMessage("filter-created"); + + filter.ondata = event => { + const script = "forceError();"; + filter.write( + new Uint8Array(new TextEncoder("utf-8").encode(script)) + ); + filter.close(); + browser.test.sendMessage("filter-ondata"); + }; + + filter.onerror = () => { + browser.test.assertEq(filter.error, "Channel redirected"); + browser.test.sendMessage("filter-redirect"); + }; + }, + { + urls: ["http://example.com/*.js"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let { messages } = await promiseConsoleOutput(async () => { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/302.html" + ); + + await extension.awaitMessage("filter-created"); + await extension.awaitMessage("filter-redirect"); + await extension.awaitMessage("filter-created"); + await extension.awaitMessage("filter-ondata"); + await contentPage.close(); + }); + AddonTestUtils.checkMessages(messages, { + expected: [{ message: /forceError is not defined/ }], + }); + + await extension.unload(); +}); + +add_task(async function test_alternate_cached_data() { + Services.prefs.setBoolPref("dom.script_loader.bytecode_cache.enabled", true); + Services.prefs.setIntPref("dom.script_loader.bytecode_cache.strategy", -1); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + browser.test.assertTrue( + str.startsWith(`"use strict";`), + "ondata received decoded data" + ); + browser.test.sendMessage("onBeforeRequest"); + }; + + filter.onerror = () => { + // onBeforeRequest will always beat the cache race, so we should always + // get valid data in ondata. + browser.test.fail("error-received", filter.error); + }; + }, + { + urls: ["http://example.com/data/file_script_good.js"], + }, + ["blocking"] + ); + browser.webRequest.onHeadersReceived.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + let decoder = new TextDecoder("utf-8"); + let encoder = new TextEncoder(); + + // Because cache is always a race, intermittently we will succesfully + // beat the cache, in which case we pass in ondata. If cache wins, + // we pass in onerror. + // Running the test with --verify hits this cache race issue, as well + // it seems that the cache primarily looses on linux1804. + let gotone = false; + filter.ondata = event => { + browser.test.assertFalse(gotone, "cache lost the race"); + gotone = true; + let str = decoder.decode(event.data, { stream: true }); + filter.write(encoder.encode(str)); + filter.disconnect(); + browser.test.assertTrue( + str.startsWith(`"use strict";`), + "ondata received decoded data" + ); + browser.test.sendMessage("onHeadersReceived"); + }; + + filter.onerror = () => { + browser.test.assertFalse(gotone, "cache won the race"); + gotone = true; + browser.test.assertEq( + filter.error, + "Channel is delivering cached alt-data" + ); + browser.test.sendMessage("onHeadersReceived"); + }; + }, + { + urls: ["http://example.com/data/file_script_bad.js"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/*"], + }, + }); + + // Prime the cache so we have the script byte-cached. + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_script.html" + ); + await contentPage.close(); + + await extension.startup(); + + let page_cached = await await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_script.html" + ); + await Promise.all([ + extension.awaitMessage("onBeforeRequest"), + extension.awaitMessage("onHeadersReceived"), + ]); + await page_cached.close(); + await extension.unload(); + + Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.enabled"); + Services.prefs.clearUserPref("dom.script_loader.bytecode_cache.strategy"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js new file mode 100644 index 0000000000..643a375ff0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filterTypes.js @@ -0,0 +1,85 @@ +"use strict"; + +AddonTestUtils.init(this); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (request, response) => { + response.setHeader("Content-Tpe", "text/plain", false); + response.write("OK"); +}); + +add_task(async function test_all_webRequest_ResourceTypes() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://example.com/*"], + }, + background() { + browser.test.onMessage.addListener(async msg => { + browser.webRequest[msg.event].addListener( + () => {}, + { urls: ["*://example.com/*"], ...msg.filter }, + ["blocking"] + ); + // Call an API method implemented in the parent process to + // be sure that the webRequest listener has been registered + // in the parent process as well. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage(`webRequest-listener-registered`); + }); + }, + }); + + await extension.startup(); + + const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + const webRequestSchema = Schemas.privilegedSchemaJSON + .get("chrome://extensions/content/schemas/web_request.json") + .deserialize({}); + const ResourceType = webRequestSchema[1].types.filter( + type => type.id == "ResourceType" + )[0]; + ok( + ResourceType && ResourceType.enum, + "Found ResourceType in the web_request.json schema" + ); + info( + "Register webRequest.onBeforeRequest event listener for all supported ResourceType" + ); + + let { messages } = await promiseConsoleOutput(async () => { + ExtensionTestUtils.failOnSchemaWarnings(false); + extension.sendMessage({ + event: "onBeforeRequest", + filter: { + // Verify that the resourceType not supported is going to be ignored + // and all the ones supported does not trigger a ChannelWrapper.matches + // exception once the listener is being triggered. + types: [].concat(ResourceType.enum, "not-supported-resource-type"), + }, + }); + await extension.awaitMessage("webRequest-listener-registered"); + ExtensionTestUtils.failOnSchemaWarnings(); + + await ExtensionTestUtils.fetch( + "http://example.com/dummy", + "http://example.com" + ); + }); + + AddonTestUtils.checkMessages(messages, { + expected: [ + { message: /Warning processing types: .* "not-supported-resource-type"/ }, + ], + forbidden: [{ message: /JavaScript Error: "ChannelWrapper.matches/ }], + }); + info("No ChannelWrapper.matches errors have been logged"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js new file mode 100644 index 0000000000..af0d8594f4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_filter_urls.js @@ -0,0 +1,35 @@ +/* Any copyright is dedicated to the Public Domain. +http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +AddonTestUtils.init(this); + +add_task(async function test_invalid_urls_in_webRequest_filter() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "https://example.com/*"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener(() => {}, { + urls: ["htt:/example.com/*"], + types: ["main_frame"], + }); + }, + }); + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + await extension.unload(); + }); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: /ExtensionError: Invalid url pattern: htt:\/example.com\/*/, + }, + ], + }, + true + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js new file mode 100644 index 0000000000..b63d14cd16 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_from_extension_page.js @@ -0,0 +1,57 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/HELLO", (req, res) => { + res.write("BYE"); +}); + +add_task(async function request_from_extension_page() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/", "webRequest", "webRequestBlocking"], + }, + files: { + "tab.html": `<!DOCTYPE html><script src="tab.js"></script>`, + "tab.js": async function() { + browser.webRequest.onHeadersReceived.addListener( + details => { + let { responseHeaders } = details; + responseHeaders.push({ + name: "X-Added-by-Test", + value: "TheValue", + }); + return { responseHeaders }; + }, + { + urls: ["http://example.com/HELLO"], + }, + ["blocking", "responseHeaders"] + ); + + // Ensure that listener is registered (workaround for bug 1300234). + await browser.runtime.getPlatformInfo(); + + let response = await fetch("http://example.com/HELLO"); + browser.test.assertEq( + "TheValue", + response.headers.get("X-added-by-test"), + "expected response header from webRequest listener" + ); + browser.test.assertEq( + await response.text(), + "BYE", + "Expected response from server" + ); + browser.test.sendMessage("done"); + }, + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `moz-extension://${extension.uuid}/tab.html`, + { extension } + ); + await extension.awaitMessage("done"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js new file mode 100644 index 0000000000..425d83560d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_host.js @@ -0,0 +1,99 @@ +"use strict"; + +const HOSTS = new Set(["example.com", "example.org"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +function getExtension(permission = "<all_urls>") { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", permission], + }, + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + details.requestHeaders.push({ name: "Host", value: "example.org" }); + return { requestHeaders: details.requestHeaders }; + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + }, + }); +} + +add_task(async function test_host_header_accepted() { + let extension = getExtension(); + await extension.startup(); + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.org", "Host header was set on request"); + + await extension.unload(); +}); + +add_task(async function test_host_header_denied() { + let extension = getExtension(`${BASE_URL}/`); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.com", "Host header was not set on request"); + + await extension.unload(); +}); + +add_task(async function test_host_header_restricted() { + Services.prefs.setCharPref( + "extensions.webextensions.restrictedDomains", + "example.org" + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextensions.restrictedDomains"); + }); + + let extension = getExtension(); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal(headers.host, "example.com", "Host header was not set on request"); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js new file mode 100644 index 0000000000..cc84791aaf --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_incognito.js @@ -0,0 +1,81 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_incognito_webrequest_access() { + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + let pb_extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertTrue(details.incognito, "incognito flag is set"); + }, + { urls: ["<all_urls>"], incognito: true }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("webRequest.spanning"); + }, + { urls: ["<all_urls>"], incognito: false }, + ["blocking"] + ); + }, + }); + await pb_extension.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("webRequest"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + }); + // Load non-incognito extension to check that private requests are invisible to it. + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitFinish("webRequest"); + await pb_extension.awaitFinish("webRequest.spanning"); + await contentPage.close(); + + await pb_extension.unload(); + await extension.unload(); + + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js new file mode 100644 index 0000000000..06dd0f54ef --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_mergecsp.js @@ -0,0 +1,214 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const server = createHttpServer({ + hosts: ["example.net", "example.com"], +}); +server.registerDirectory("/data/", do_get_file("data")); + +const pageContent = `<!DOCTYPE html> + <script id="script1" src="/data/file_script_good.js"></script> + <script id="script3" src="//example.com/data/file_script_bad.js"></script> + <img id="img1" src='/data/file_image_good.png'> + <img id="img3" src='//example.com/data/file_image_good.png'> +`; + +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html"); + if (request.queryString) { + response.setHeader( + "Content-Security-Policy", + decodeURIComponent(request.queryString) + ); + } + response.write(pageContent); +}); + +let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://example.net/*"], + }, + background() { + let csp_value = undefined; + browser.test.onMessage.addListener(function(msg, expectedCount) { + csp_value = msg; + browser.test.sendMessage("csp-set"); + }); + browser.webRequest.onHeadersReceived.addListener( + e => { + browser.test.log(`onHeadersReceived ${e.requestId} ${e.url}`); + if (csp_value === undefined) { + browser.test.assertTrue(false, "extension called before CSP was set"); + } + if (csp_value !== null) { + e.responseHeaders = e.responseHeaders.filter( + i => i.name.toLowerCase() != "content-security-policy" + ); + if (csp_value !== "") { + e.responseHeaders.push({ + name: "Content-Security-Policy", + value: csp_value, + }); + } + } + return { responseHeaders: e.responseHeaders }; + }, + { urls: ["*://example.net/*"] }, + ["blocking", "responseHeaders"] + ); + }, +}; + +/** + * Test a combination of Content Security Policies against first/third party images/scripts. + * @param {string} site_csp The CSP to be sent by the site, or null. + * @param {string} ext1_csp The CSP to be sent by the first extension, + * "" to remove the header, or null to not modify it. + * @param {string} ext2_csp The CSP to be sent by the first extension, + * "" to remove the header, or null to not modify it. + * @param {Object} expect Object containing information which resources are expected to be loaded. + * @param {Object} expect.img1_loaded image from a first party origin. + * @param {Object} expect.img3_loaded image from a third party origin. + * @param {Object} expect.script1_loaded script from a first party origin. + * @param {Object} expect.script3_loaded script from a third party origin. + */ +async function test_csp(site_csp, ext1_csp, ext2_csp, expect) { + let extension1 = await ExtensionTestUtils.loadExtension(extensionData); + let extension2 = await ExtensionTestUtils.loadExtension(extensionData); + await extension1.startup(); + await extension2.startup(); + extension1.sendMessage(ext1_csp); + extension2.sendMessage(ext2_csp); + await extension1.awaitMessage("csp-set"); + await extension2.awaitMessage("csp-set"); + + let csp_value = encodeURIComponent(site_csp || ""); + let contentPage = await ExtensionTestUtils.loadContentPage( + `http://example.net/?${csp_value}` + ); + let results = await contentPage.spawn(null, async () => { + let img1 = this.content.document.getElementById("img1"); + let img3 = this.content.document.getElementById("img3"); + return { + img1_loaded: img1.complete && img1.naturalWidth > 0, + img3_loaded: img3.complete && img3.naturalWidth > 0, + // Note: "good" and "bad" are just placeholders; they don't mean anything. + script1_loaded: !!this.content.document.getElementById("good"), + script3_loaded: !!this.content.document.getElementById("bad"), + }; + }); + + await contentPage.close(); + await extension1.unload(); + await extension2.unload(); + + let action = { + true: "loaded", + false: "blocked", + }; + + info(`test_csp: From "${site_csp}" to "${ext1_csp}" to "${ext2_csp}"`); + + equal( + expect.img1_loaded, + results.img1_loaded, + `expected first party image to be ${action[expect.img1_loaded]}` + ); + equal( + expect.img3_loaded, + results.img3_loaded, + `expected third party image to be ${action[expect.img3_loaded]}` + ); + equal( + expect.script1_loaded, + results.script1_loaded, + `expected first party script to be ${action[expect.script1_loaded]}` + ); + equal( + expect.script3_loaded, + results.script3_loaded, + `expected third party script to be ${action[expect.script3_loaded]}` + ); +} + +add_task(async function test_webRequest_mergecsp() { + await test_csp("default-src *", "script-src 'none'", null, { + img1_loaded: true, + img3_loaded: true, + script1_loaded: false, + script3_loaded: false, + }); + await test_csp(null, "script-src 'none'", null, { + img1_loaded: true, + img3_loaded: true, + script1_loaded: false, + script3_loaded: false, + }); + await test_csp("default-src *", "script-src 'none'", "img-src 'none'", { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + }); + await test_csp(null, "script-src 'none'", "img-src 'none'", { + img1_loaded: false, + img3_loaded: false, + script1_loaded: false, + script3_loaded: false, + }); + await test_csp( + "default-src *", + "img-src example.com", + "img-src example.org", + { + img1_loaded: false, + img3_loaded: false, + script1_loaded: true, + script3_loaded: true, + } + ); +}); + +add_task(async function test_remove_and_replace_csp() { + // CSP removed, CSP added. + await test_csp("img-src 'self'", "", "img-src example.com", { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); + + // CSP removed, CSP added. + await test_csp("default-src 'none'", "", "img-src example.com", { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); + + // CSP replaced - regression test for bug 1635781. + await test_csp("default-src 'none'", "img-src example.com", null, { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); + + // CSP unchanged, CSP replaced - regression test for bug 1635781. + await test_csp("default-src 'none'", null, "img-src example.com", { + img1_loaded: false, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); + + // CSP replaced, CSP removed. + await test_csp("default-src 'none'", "img-src example.com", "", { + img1_loaded: true, + img3_loaded: true, + script1_loaded: true, + script3_loaded: true, + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js new file mode 100644 index 0000000000..530deaa1a7 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_permission.js @@ -0,0 +1,154 @@ +"use strict"; + +const PREF_DISABLE_SECURITY = + "security.turn_off_all_security_so_that_" + + "viruses_can_take_over_this_computer"; + +const HOSTS = new Set(["example.com", "example.org"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_permissions() { + function background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + if (details.url.includes("_original")) { + let redirectUrl = details.url + .replace("example.org", "example.com") + .replace("_original", "_redirected"); + return { redirectUrl }; + } + return {}; + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + } + + let extensionData = { + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + const frameScript = () => { + const messageListener = { + async receiveMessage({ target, messageName, recipient, data, name }) { + /* globals content */ + let doc = content.document; + let iframe = doc.createElement("iframe"); + doc.body.appendChild(iframe); + + let promise = new Promise(resolve => { + let listener = event => { + content.removeEventListener("message", listener); + resolve(event.data); + }; + content.addEventListener("message", listener); + }); + + iframe.setAttribute( + "src", + "http://example.com/data/file_WebRequest_permission_original.html" + ); + let result = await promise; + doc.body.removeChild(iframe); + return result; + }, + }; + + const { MessageChannel } = ChromeUtils.import( + "resource://gre/modules/MessageChannel.jsm" + ); + MessageChannel.addListener(this, "Test:Check", messageListener); + }; + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await contentPage.loadFrameScript(frameScript); + + let results = await contentPage.sendMessage("Test:Check", {}); + equal( + results.page, + "redirected", + "Regular webRequest redirect works on an unprivileged page" + ); + equal( + results.script, + "redirected", + "Regular webRequest redirect works from an unprivileged page" + ); + + Services.prefs.setBoolPref(PREF_DISABLE_SECURITY, true); + Services.prefs.setBoolPref("extensions.webapi.testing", true); + Services.prefs.setBoolPref("extensions.webapi.testing.http", true); + + results = await contentPage.sendMessage("Test:Check", {}); + equal( + results.page, + "original", + "webRequest redirect fails on a privileged page" + ); + equal( + results.script, + "original", + "webRequest redirect fails from a privileged page" + ); + + await extension.unload(); + await contentPage.close(); +}); + +add_task(async function test_no_webRequestBlocking_error() { + function background() { + const expectedError = + "Using webRequest.addListener with the blocking option " + + "requires the 'webRequestBlocking' permission."; + + const blockingEvents = [ + "onBeforeRequest", + "onBeforeSendHeaders", + "onHeadersReceived", + "onAuthRequired", + ]; + + for (let eventName of blockingEvents) { + browser.test.assertThrows( + () => { + browser.webRequest[eventName].addListener( + details => {}, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + expectedError, + `Got the expected exception for a blocking webRequest.${eventName} listener` + ); + } + } + + const extensionData = { + manifest: { permissions: ["webRequest", "<all_urls>"] }, + background, + }; + + const extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js new file mode 100644 index 0000000000..f8d329c85b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_StreamFilter.js @@ -0,0 +1,129 @@ +"use strict"; + +// StreamFilters should be closed upon a redirect. +// +// Some redirects are already tested in other tests: +// - test_ext_webRequest_filterResponseData.js tests fetch requests. +// - test_ext_webRequest_viewsource_StreamFilter.js tests view-source documents. +// +// Usually, redirects are caught in StreamFilterParent::OnStartRequest, but due +// to the fact that AttachStreamFilter is deferred for document requests, OSR is +// not called and the cleanup is triggered from nsHttpChannel::ReleaseListeners. + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); + +server.registerPathHandler("/redir", (request, response) => { + response.setStatusLine(request.httpVersion, 302, "Found"); + response.setHeader("Location", "/target"); +}); +server.registerPathHandler("/target", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); +server.registerPathHandler("/RedirectToRedir.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.write("<script>location.href='http://example.com/redir';</script>"); +}); +server.registerPathHandler("/iframeWithRedir.html", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html; charset=utf-8"); + response.write("<iframe src='http://example.com/redir'></iframe>"); +}); + +function loadRedirectCatcherExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background() { + const closeCounts = {}; + browser.webRequest.onBeforeRequest.addListener( + details => { + let expectedError = "Channel redirected"; + if (details.type === "main_frame" || details.type === "sub_frame") { + // Message differs for the reason stated at the top of this file. + // TODO bug 1683862: Make error message more accurate. + expectedError = "Invalid request ID"; + } + + closeCounts[details.requestId] = 0; + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.disconnect(); + browser.test.fail("Unexpected filter.onstart"); + }; + filter.onerror = function() { + closeCounts[details.requestId]++; + browser.test.assertEq(expectedError, filter.error, "filter.error"); + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + browser.webRequest.onCompleted.addListener( + details => { + // filter.onerror from the redirect request should be called before + // webRequest.onCompleted of the redirection target. Regression test + // for bug 1683189. + browser.test.assertEq( + 1, + closeCounts[details.requestId], + "filter from initial, redirected request should have been closed" + ); + browser.test.log("Intentionally canceling view-source request"); + browser.test.sendMessage("req_end", details.type); + }, + { urls: ["*://*/target"] } + ); + }, + }); +} + +add_task(async function redirect_document() { + let extension = loadRedirectCatcherExtension(); + await extension.startup(); + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/redir" + ); + equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc"); + await contentPage.close(); + } + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/iframeWithRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc"); + await contentPage.close(); + } + + await extension.unload(); +}); + +// Cross-origin redirect = process switch. +add_task(async function redirect_document_cross_origin() { + let extension = loadRedirectCatcherExtension(); + await extension.startup(); + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/RedirectToRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "main_frame", "is top doc"); + await contentPage.close(); + } + + { + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.org/iframeWithRedir.html" + ); + equal(await extension.awaitMessage("req_end"), "sub_frame", "is sub doc"); + await contentPage.close(); + } + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js new file mode 100644 index 0000000000..e390e3348e --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_redirect_mozextension.js @@ -0,0 +1,47 @@ +"use strict"; + +// See: https://bugzilla.mozilla.org/show_bug.cgi?id=1573456 +add_task(async function test_mozextension_page_loaded_in_extension_process() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "https://example.com/*", + ], + web_accessible_resources: ["test.html"], + }, + files: { + "test.html": '<!DOCTYPE html><script src="test.js"></script>', + "test.js": () => { + browser.test.assertTrue( + browser.webRequest, + "webRequest API should be available" + ); + + browser.test.sendMessage("test_done"); + }, + }, + background: () => { + browser.webRequest.onBeforeRequest.addListener( + () => { + return { + redirectUrl: browser.runtime.getURL("test.html"), + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "https://example.com/redir" + ); + + await extension.awaitMessage("test_done"); + + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js new file mode 100644 index 0000000000..69238fb057 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_requestSize.js @@ -0,0 +1,57 @@ +"use strict"; + +const server = createHttpServer(); +const gServerUrl = `http://localhost:${server.identity.primaryPort}`; + +const EXTENSION_DATA = { + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + + permissions: ["webRequest", "<all_urls>"], + }, + + async background() { + browser.test.log("background script running"); + + browser.webRequest.onBeforeSendHeaders.addListener( + async details => { + browser.test.assertTrue(details.requestSize == 0, "no requestSize"); + browser.test.assertTrue(details.responseSize == 0, "no responseSize"); + browser.test.log(`details.requestSize: ${details.requestSize}`); + browser.test.log(`details.responseSize: ${details.responseSize}`); + browser.test.sendMessage("check"); + }, + { urls: ["*://*/*"] } + ); + + browser.webRequest.onCompleted.addListener( + async details => { + browser.test.assertTrue(details.requestSize > 100, "have requestSize"); + browser.test.assertTrue( + details.responseSize > 100, + "have responseSize" + ); + browser.test.log(`details.requestSize: ${details.requestSize}`); + browser.test.log(`details.responseSize: ${details.responseSize}`); + browser.test.sendMessage("done"); + }, + { urls: ["*://*/*"] } + ); + }, +}; + +add_task(async function test_request_response_size() { + let ext = ExtensionTestUtils.loadExtension(EXTENSION_DATA); + await ext.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${gServerUrl}/dummy` + ); + await ext.awaitMessage("check"); + await ext.awaitMessage("done"); + await contentPage.close(); + await ext.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js new file mode 100644 index 0000000000..d3715684f9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_responseBody.js @@ -0,0 +1,765 @@ +"use strict"; + +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +/* eslint-disable no-shadow */ + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { ExtensionTestCommon } = ChromeUtils.import( + "resource://testing-common/ExtensionTestCommon.jsm" +); + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/data/file_sample.html"; + +const SEQUENTIAL = false; + +const PARTS = [ + `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body>`, + "Lorem ipsum dolor sit amet, <br>", + "consectetur adipiscing elit, <br>", + "sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. <br>", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. <br>", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. <br>", + "Excepteur sint occaecat cupidatat non proident, <br>", + "sunt in culpa qui officia deserunt mollit anim id est laborum.<br>", + ` + </body> + </html>`, +].map(part => `${part}\n`); + +const TIMEOUT = AppConstants.DEBUG ? 4000 : 800; + +function delay(timeout = TIMEOUT) { + return new Promise(resolve => setTimeout(resolve, timeout)); +} + +server.registerPathHandler("/slow_response.sjs", async (request, response) => { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + + await delay(); + + for (let part of PARTS) { + try { + response.write(part); + } catch (e) { + // This fails if we attempt to write data after the connection has + // been closed. + break; + } + await delay(); + } + + response.finish(); +}); + +server.registerPathHandler("/lorem.html.gz", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + "Content-Type: text/html; charset=utf-8", + false + ); + response.setHeader("Content-Encoding", "gzip", false); + + let data = await OS.File.read(do_get_file("data/lorem.html.gz").path); + response.write(String.fromCharCode(...new Uint8Array(data))); + + response.finish(); +}); + +server.registerPathHandler("/multipart", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"', + false + ); + + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting--\n"); + + response.finish(); +}); + +server.registerPathHandler("/multipart2", async (request, response) => { + response.processAsync(); + + response.setHeader( + "Content-Type", + 'Content-Type: multipart/x-mixed-replace; boundary="testingtesting"', + false + ); + + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting\n"); + response.write(PARTS.join("")); + response.write("--testingtesting--\n"); + + response.finish(); +}); + +server.registerDirectory("/data/", do_get_file("data")); + +const TASKS = [ + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let decoder = new TextDecoder("utf-8"); + + browser.test.assertEq( + "uninitialized", + filter.status, + `(${num}): Got expected initial status` + ); + + filter.onstart = event => { + browser.test.assertEq( + "transferringdata", + filter.status, + `(${num}): Got expected onStart status` + ); + }; + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + let str = decoder.decode(event.data, { stream: true }); + + if (n < 3) { + browser.test.assertEq( + JSON.stringify(PARTS[n]), + JSON.stringify(str), + `(${num}): Got expected part` + ); + } + n++; + + filter.write(event.data); + + if (n == 3) { + filter.suspend(); + + browser.test.assertEq( + "suspended", + filter.status, + `(${num}): Got expected suspended status` + ); + + let fail = () => { + browser.test.fail( + `(${num}): Got unexpected data event while suspended` + ); + }; + filter.addEventListener("data", fail); + + await delay(TIMEOUT * 3); + + browser.test.assertEq( + "suspended", + filter.status, + `(${num}): Got expected suspended status` + ); + + filter.removeEventListener("data", fail); + filter.resume(); + browser.test.assertEq( + "transferringdata", + filter.status, + `(${num}): Got expected resumed status` + ); + } else if (n > 4) { + filter.disconnect(); + + filter.addEventListener("data", () => { + browser.test.fail( + `(${num}): Got unexpected data event while disconnected` + ); + }); + + browser.test.assertEq( + "disconnected", + filter.status, + `(${num}): Got expected disconnected status` + ); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let decoder = new TextDecoder("utf-8"); + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + let str = decoder.decode(event.data, { stream: true }); + + if (n < 3) { + browser.test.assertEq( + JSON.stringify(PARTS[n]), + JSON.stringify(str), + `(${num}): Got expected part` + ); + } + n++; + + filter.write(event.data); + + if (n == 3) { + filter.suspend(); + + await delay(TIMEOUT * 3); + + filter.disconnect(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let encoder = new TextEncoder("utf-8"); + + filter.onstop = event => { + browser.test.fail( + `(${num}): Got unexpected onStop event while disconnected` + ); + }; + + let n = 0; + filter.ondata = async event => { + n++; + + filter.write(event.data); + + function checkState(state) { + browser.test.assertEq( + state, + filter.status, + `(${num}): Got expected status` + ); + } + if (n == 3) { + filter.resume(); + checkState("transferringdata"); + filter.suspend(); + checkState("suspended"); + filter.suspend(); + checkState("suspended"); + filter.resume(); + checkState("transferringdata"); + filter.suspend(); + checkState("suspended"); + + await delay(TIMEOUT * 3); + + checkState("suspended"); + filter.disconnect(); + checkState("disconnected"); + + for (let method of ["suspend", "resume", "close"]) { + browser.test.assertThrows( + () => { + filter[method](); + }, + /.*/, + `(${num}): ${method}() should throw while disconnected` + ); + } + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw while disconnected` + ); + + filter.disconnect(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "slow_response.sjs", + task(filter, resolve, num) { + let encoder = new TextEncoder("utf-8"); + let decoder = new TextDecoder("utf-8"); + + filter.onstop = event => { + browser.test.fail(`(${num}): Got unexpected onStop event while closed`); + }; + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw prior to connection` + ); + + let n = 0; + filter.ondata = async event => { + n++; + + filter.write(event.data); + + browser.test.log( + `(${num}): Got part ${n}: ${JSON.stringify( + decoder.decode(event.data) + )}` + ); + + function checkState(state) { + browser.test.assertEq( + state, + filter.status, + `(${num}): Got expected status` + ); + } + if (n == 3) { + filter.close(); + + checkState("closed"); + + for (let method of ["suspend", "resume", "disconnect"]) { + browser.test.assertThrows( + () => { + filter[method](); + }, + /.*/, + `(${num}): ${method}() should throw while closed` + ); + } + + browser.test.assertThrows( + () => { + filter.write(encoder.encode("Foo bar")); + }, + /.*/, + `(${num}): write() should throw while closed` + ); + + filter.close(); + + resolve(); + } + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.slice(0, 3).join(""), "Got expected final HTML"); + }, + }, + { + url: "lorem.html.gz", + task(filter, resolve, num) { + let response = ""; + let decoder = new TextDecoder("utf-8"); + + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + browser.test.assertEq( + "finishedtransferringdata", + filter.status, + `(${num}): Got expected onStop status` + ); + + filter.close(); + browser.test.assertEq( + "closed", + filter.status, + `Got expected closed status` + ); + + browser.test.assertEq( + JSON.stringify(PARTS.join("")), + JSON.stringify(response), + `(${num}): Got expected response` + ); + + resolve(); + }; + + filter.ondata = event => { + let str = decoder.decode(event.data, { stream: true }); + response += str; + + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal(response, PARTS.join(""), "Got expected final HTML"); + }, + }, + { + url: "multipart", + task(filter, resolve, num) { + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + filter.disconnect(); + resolve(); + }; + + filter.ondata = event => { + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal( + response, + "--testingtesting\n" + PARTS.join("") + "--testingtesting--\n", + "Got expected final HTML" + ); + }, + }, + { + url: "multipart2", + task(filter, resolve, num) { + filter.onstart = event => { + browser.test.log(`(${num}): Request start`); + }; + + filter.onstop = event => { + filter.disconnect(); + resolve(); + }; + + filter.ondata = event => { + filter.write(event.data); + }; + + filter.onerror = event => { + browser.test.fail( + `(${num}): Got unexpected error event: ${filter.error}` + ); + }; + }, + verify(response) { + equal( + response, + "--testingtesting\n" + + PARTS.join("") + + "--testingtesting\n" + + PARTS.join("") + + "--testingtesting--\n", + "Got expected final HTML" + ); + }, + }, +]; + +function serializeTest(test, num) { + let url = `${test.url}?test_num=${num}`; + let task = ExtensionTestCommon.serializeFunction(test.task); + + return `{url: ${JSON.stringify(url)}, task: ${task}}`; +} + +add_task(async function() { + function background(TASKS) { + async function runTest(test, num, details) { + browser.test.log(`Running test #${num}: ${details.url}`); + + let filter = browser.webRequest.filterResponseData(details.requestId); + + try { + await new Promise(resolve => { + test.task(filter, resolve, num, details); + }); + } catch (e) { + browser.test.fail( + `Task #${num} threw an unexpected exception: ${e} :: ${e.stack}` + ); + } + + browser.test.log(`Finished test #${num}: ${details.url}`); + browser.test.sendMessage(`finished-${num}`); + } + + browser.webRequest.onBeforeRequest.addListener( + details => { + for (let [num, test] of TASKS.entries()) { + if (details.url.endsWith(test.url)) { + runTest(test, num, details); + break; + } + } + }, + { + urls: ["http://example.com/*?test_num=*"], + }, + ["blocking"] + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: ` + const PARTS = ${JSON.stringify(PARTS)}; + const TIMEOUT = ${TIMEOUT}; + + ${delay} + + (${background})([${TASKS.map(serializeTest)}]) + `, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + async function runTest(test, num) { + let url = `${BASE_URL}/${test.url}?test_num=${num}`; + + let body = await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + + await extension.awaitMessage(`finished-${num}`); + + info(`Verifying test #${num}: ${url}`); + await test.verify(body); + } + + if (SEQUENTIAL) { + for (let [num, test] of TASKS.entries()) { + await runTest(test, num); + } + } else { + await Promise.all(TASKS.map(runTest)); + } + + await extension.unload(); +}); + +// Test that registering a listener for a cached response does not cause a crash. +add_task(async function test_cachedResponse() { + if (AppConstants.platform === "android") { + return; + } + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onHeadersReceived.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = event => { + filter.close(); + }; + filter.ondata = event => { + filter.write(event.data); + }; + + if (data.fromCache) { + browser.test.sendMessage("from-cache"); + } + }, + { + urls: ["http://example.com/*/file_sample.html?r=*"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + let url = `${BASE_URL}/data/file_sample.html?r=${Math.random()}`; + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await ExtensionTestUtils.fetch(FETCH_ORIGIN, url); + await extension.awaitMessage("from-cache"); + + await extension.unload(); +}); + +// Test that finishing transferring data doesn't overwrite an existing closing/closed state. +add_task(async function test_late_close() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.webRequest.onBeforeRequest.addListener( + data => { + let filter = browser.webRequest.filterResponseData(data.requestId); + + filter.onstop = event => { + browser.test.fail("Should not receive onstop after close()"); + browser.test.assertEq( + "closed", + filter.status, + "Filter status should still be 'closed'" + ); + browser.test.assertThrows(() => { + filter.close(); + }); + }; + filter.ondata = event => { + filter.write(event.data); + filter.close(); + + browser.test.sendMessage(`done-${data.url}`); + }; + }, + { + urls: ["http://example.com/*/file_sample.html?*"], + }, + ["blocking"] + ); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + + // This issue involves a race, so several requests in parallel to increase + // the chances of triggering it. + let urls = []; + for (let i = 0; i < 32; i++) { + urls.push(`${BASE_URL}/data/file_sample.html?r=${Math.random()}`); + } + + await Promise.all( + urls.map(url => ExtensionTestUtils.fetch(FETCH_ORIGIN, url)) + ); + await Promise.all(urls.map(url => extension.awaitMessage(`done-${url}`))); + + await extension.unload(); +}); + +add_task(async function test_permissions() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.assertEq( + undefined, + browser.webRequest.filterResponseData, + "filterResponseData is undefined without blocking permissions" + ); + }, + + manifest: { + permissions: ["webRequest", "http://example.com/"], + }, + }); + + await extension.startup(); + await extension.unload(); +}); + +add_task(async function test_invalidId() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let filter = browser.webRequest.filterResponseData("34159628"); + + await new Promise(resolve => { + filter.onerror = resolve; + }); + + browser.test.assertEq( + "Invalid request ID", + filter.error, + "Got expected error" + ); + + browser.test.notifyPass("invalid-request-id"); + }, + + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("invalid-request-id"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js new file mode 100644 index 0000000000..e40bc4f8b4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_set_cookie.js @@ -0,0 +1,308 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +server.registerDirectory("/data/", do_get_file("data")); + +server.registerPathHandler( + "/file_webrequestblocking_set_cookie.html", + (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Set-Cookie", "reqcookie=reqvalue", false); + response.write("<!DOCTYPE html><html></html>"); + } +); + +add_task(async function test_modifying_cookies_from_onHeadersReceived() { + async function background() { + /** + * Check that all the cookies described by `prefixes` are in the cookie jar. + * + * @param {Array.string} prefixes + * Zero or more prefixes, describing cookies that are expected to be set + * in the current cookie jar. Each prefix describes both a cookie + * name and corresponding value. For example, if the string "ext" + * is passed as an argument, then this function expects to see + * a cookie called "extcookie" and corresponding value of "extvalue". + */ + async function checkCookies(prefixes) { + const numPrefixes = prefixes.length; + const currentCookies = await browser.cookies.getAll({}); + browser.test.assertEq( + numPrefixes, + currentCookies.length, + `${numPrefixes} cookies were set` + ); + + for (let cookiePrefix of prefixes) { + let cookieName = `${cookiePrefix}cookie`; + let expectedCookieValue = `${cookiePrefix}value`; + let fetchedCookie = await browser.cookies.getAll({ name: cookieName }); + browser.test.assertEq( + 1, + fetchedCookie.length, + `Found 1 cookie with name "${cookieName}"` + ); + browser.test.assertEq( + expectedCookieValue, + fetchedCookie[0] && fetchedCookie[0].value, + `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"` + ); + } + } + + function awaitMessage(expectedMsg) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(msg) { + if (msg === expectedMsg) { + browser.test.onMessage.removeListener(listener); + resolve(); + } + }); + }); + } + + /** + * Opens the given test file as a content page. + * + * @param {string} filename + * The name of a html file relative to the test server root. + * + * @returns {Promise} + */ + function openContentPage(filename) { + let promise = awaitMessage("url-loaded"); + browser.test.sendMessage( + "load-url", + `http://example.com/${filename}?nocache=${Math.random()}` + ); + return promise; + } + + /** + * Tests that expected cookies are in the cookie jar after opening a file. + * + * @param {string} filename + * The name of a html file in the + * "toolkit/components/extensions/test/mochitest" directory. + * @param {?Array.string} prefixes + * Zero or more prefixes, describing cookies that are expected to be set + * in the current cookie jar. Each prefix describes both a cookie + * name and corresponding value. For example, if the string "ext" + * is passed as an argument, then this function expects to see + * a cookie called "extcookie" and corresponding value of "extvalue". + * If undefined, then no checks are automatically performed, and the + * caller should provide a callback to perform the checks. + * @param {?Function} callback + * An optional async callback function that, if provided, will be called + * with an object that contains windowId and tabId parameters. + * Callers can use this callback to apply extra tests about the state of + * the cookie jar, or to query the state of the opened page. + */ + async function testCookiesWithFile(filename, prefixes, callback) { + await browser.browsingData.removeCookies({}); + await openContentPage(filename); + + if (prefixes !== undefined) { + await checkCookies(prefixes); + } + + if (callback !== undefined) { + await callback(); + } + let promise = awaitMessage("url-unloaded"); + browser.test.sendMessage("unload-url"); + await promise; + } + + const filter = { + urls: ["<all_urls>"], + types: ["main_frame", "sub_frame"], + }; + + const headersReceivedInfoSpec = ["blocking", "responseHeaders"]; + + const onHeadersReceived = details => { + details.responseHeaders.push({ + name: "Set-Cookie", + value: "extcookie=extvalue", + }); + + return { + responseHeaders: details.responseHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + onHeadersReceived, + filter, + headersReceivedInfoSpec + ); + + // First, perform a request that should not set any cookies, and check + // that the cookie the extension sets is the only cookie in the + // cookie jar. + await testCookiesWithFile("data/file_sample.html", ["ext"]); + + // Next, perform a request that will set on cookie (reqcookie=reqvalue) + // and check that two cookies wind up in the cookie jar (the request + // set cookie, and the extension set cookie). + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + "req", + ]); + + // Third, register another onHeadersReceived handler that also + // sets a cookie (thirdcookie=thirdvalue), to make sure modifications from + // multiple onHeadersReceived listeners are merged correctly. + const thirdOnHeadersRecievedListener = details => { + details.responseHeaders.push({ + name: "Set-Cookie", + value: "thirdcookie=thirdvalue", + }); + + browser.test.log(JSON.stringify(details.responseHeaders)); + + return { + responseHeaders: details.responseHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + thirdOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + "req", + "third", + ]); + browser.webRequest.onHeadersReceived.removeListener(onHeadersReceived); + browser.webRequest.onHeadersReceived.removeListener( + thirdOnHeadersRecievedListener + ); + + // Fourth, test to make sure that extensions can remove cookies + // using onHeadersReceived too, by 1. making a request that + // sets a cookie (reqcookie=reqvalue), 2. having the extension remove + // that cookie by removing that header, and 3. adding a new cookie + // (extcookie=extvalue). + const fourthOnHeadersRecievedListener = details => { + // Remove the cookie set by the request (reqcookie=reqvalue). + const newHeaders = details.responseHeaders.filter( + cookie => cookie.name !== "set-cookie" + ); + + // And then add a new cookie in its place (extcookie=extvalue). + newHeaders.push({ + name: "Set-Cookie", + value: "extcookie=extvalue", + }); + + return { + responseHeaders: newHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + fourthOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + await testCookiesWithFile("file_webrequestblocking_set_cookie.html", [ + "ext", + ]); + browser.webRequest.onHeadersReceived.removeListener( + fourthOnHeadersRecievedListener + ); + + // Fifth, check that extensions are able to overwrite headers set by + // pages. In this test, make a request that will set "reqcookie=reqvalue", + // and add a listener that sets "reqcookie=changedvalue". Check + // to make sure that the cookie jar contains "reqcookie=changedvalue" + // and not "reqcookie=reqvalue". + const fifthOnHeadersRecievedListener = details => { + // Remove the cookie set by the request (reqcookie=reqvalue). + const newHeaders = details.responseHeaders.filter( + cookie => cookie.name !== "set-cookie" + ); + + // And then add a new cookie in its place (reqcookie=changedvalue). + newHeaders.push({ + name: "Set-Cookie", + value: "reqcookie=changedvalue", + }); + + return { + responseHeaders: newHeaders, + }; + }; + browser.webRequest.onHeadersReceived.addListener( + fifthOnHeadersRecievedListener, + filter, + headersReceivedInfoSpec + ); + + await testCookiesWithFile( + "file_webrequestblocking_set_cookie.html", + undefined, + async () => { + const currentCookies = await browser.cookies.getAll({}); + browser.test.assertEq(1, currentCookies.length, `1 cookie was set`); + + const cookieName = "reqcookie"; + const expectedCookieValue = "changedvalue"; + const fetchedCookie = await browser.cookies.getAll({ + name: cookieName, + }); + + browser.test.assertEq( + 1, + fetchedCookie.length, + `Found 1 cookie with name "${cookieName}"` + ); + browser.test.assertEq( + expectedCookieValue, + fetchedCookie[0] && fetchedCookie[0].value, + `Cookie "${cookieName}" has expected value of "${expectedCookieValue}"` + ); + } + ); + browser.webRequest.onHeadersReceived.removeListener( + fifthOnHeadersRecievedListener + ); + + browser.test.notifyPass("cookie modifying extension"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "browsingData", + "cookies", + "webNavigation", + "webRequest", + "webRequestBlocking", + "<all_urls>", + ], + }, + background, + }); + + let contentPage = null; + extension.onMessage("load-url", async url => { + ok(!contentPage, "Should have no content page to unload"); + contentPage = await ExtensionTestUtils.loadContentPage(url); + extension.sendMessage("url-loaded"); + }); + extension.onMessage("unload-url", async () => { + await contentPage.close(); + contentPage = null; + extension.sendMessage("url-unloaded"); + }); + + await extension.startup(); + await extension.awaitFinish("cookie modifying extension"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js new file mode 100644 index 0000000000..0528d97298 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup.js @@ -0,0 +1,603 @@ +"use strict"; + +// Delay loading until createAppInfo is called and setup. +ChromeUtils.defineModuleGetter( + this, + "AddonManager", + "resource://gre/modules/AddonManager.jsm" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +// The app and platform version here should be >= of the version set in the extensions.webExtensionsMinPlatformVersion preference, +// otherwise test_persistent_listener_after_staged_update will fail because no compatible updates will be found. +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let scopes = AddonManager.SCOPE_PROFILE | AddonManager.SCOPE_APPLICATION; +Services.prefs.setIntPref("extensions.enabledScopes", scopes); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +function trackEvents(wrapper) { + let events = new Map(); + for (let event of ["background-page-event", "start-background-page"]) { + events.set(event, false); + wrapper.extension.once(event, () => events.set(event, true)); + } + return events; +} + +async function testPersistentRequestStartup(extension, events, expect) { + equal( + events.get("background-page-event"), + expect.background, + "Should have gotten a background page event" + ); + equal( + events.get("start-background-page"), + false, + "Background page should not be started" + ); + + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + await ExtensionParent.browserPaintedPromise; + + equal( + events.get("start-background-page"), + expect.delayedStart, + "Should have gotten start-background-page event" + ); + + if (expect.request) { + await extension.awaitMessage("got-request"); + ok(true, "Background page loaded and received webRequest event"); + } +} + +// Test that a non-blocking listener during startup does not immediately +// start the background page, but the event is queued until the background +// page is started. +add_task(async function test_1() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["webRequest", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + }, + }); + + await extension.startup(); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: true, + delayedStart: true, + request: true, + }); + + await extension.unload(); + + await promiseShutdownManager(); +}); + +// Tests that filters are handled properly: if we have a blocking listener +// with a filter, a request that does not match the filter does not get +// suspended and does not start the background page. +add_task(async function test_2() { + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "http://test1.example.com/", + ], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.fail("Listener should not have been called"); + }, + { urls: ["http://test1.example.com/*"] }, + ["blocking"] + ); + + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: false, + delayedStart: false, + request: false, + }); + + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + await extension.awaitMessage("ready"); + + await extension.unload(); + await promiseShutdownManager(); +}); + +// Tests that moving permission to optional retains permission and that the +// persistent listeners are used as expected. +add_task(async function test_persistent_listener_after_sideload_upgrade() { + let id = "permission-sideload-upgrade@test"; + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { gecko: { id } }, + permissions: ["webRequest", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + }, + }; + let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + + let extension = ExtensionTestUtils.expectExtension(id); + await AddonTestUtils.manuallyInstall(xpi); + await promiseStartupManager(); + await extension.awaitStartup(); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + + await promiseShutdownManager(); + + // Prepare a sideload update for the extension. + extensionData.manifest.version = "2.0"; + extensionData.manifest.permissions = ["http://example.com/"]; + extensionData.manifest.optional_permissions = ["webRequest"]; + xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + await AddonTestUtils.manuallyInstall(xpi); + + ExtensionParent._resetStartupPromises(); + await promiseStartupManager(); + await extension.awaitStartup(); + let events = trackEvents(extension); + + // Verify webRequest permission. + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("webRequest"), "addon webRequest permission added"); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: true, + delayedStart: true, + request: true, + }); + + await extension.unload(); + await promiseShutdownManager(); +}); + +// Utility to install builtin addon +async function installBuiltinExtension(extensionData) { + let xpi = await AddonTestUtils.createTempWebExtensionFile(extensionData); + + // The built-in location requires a resource: URL that maps to a + // jar: or file: URL. This would typically be something bundled + // into omni.ja but for testing we just use a temp file. + let base = Services.io.newURI(`jar:file:${xpi.path}!/`); + let resProto = Services.io + .getProtocolHandler("resource") + .QueryInterface(Ci.nsIResProtocolHandler); + resProto.setSubstitution("ext-test", base); + return AddonManager.installBuiltinAddon("resource://ext-test/"); +} + +function promisePostponeInstall(install) { + return new Promise((resolve, reject) => { + let listener = { + onInstallFailed: () => { + install.removeListener(listener); + reject(new Error("extension installation should not have failed")); + }, + onInstallEnded: () => { + install.removeListener(listener); + reject( + new Error( + `extension installation should not have ended for ${install.addon.id}` + ) + ); + }, + onInstallPostponed: () => { + install.removeListener(listener); + resolve(); + }, + }; + + install.addListener(listener); + install.install(); + }); +} + +// Tests that moving permission to optional retains permission and that the +// persistent listeners are used as expected. +add_task( + async function test_persistent_listener_after_builtin_location_upgrade() { + let id = "permission-builtin-upgrade@test"; + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { gecko: { id } }, + permissions: ["webRequest", "http://example.com/"], + }, + + async background() { + browser.runtime.onUpdateAvailable.addListener(() => { + browser.test.sendMessage("postponed"); + }); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + }, + }; + await promiseStartupManager(); + // If we use an extension wrapper via ExtensionTestUtils.expectExtension + // it will continue to handle messages even after the update, resulting + // in errors when it receives additional messages without any awaitMessage. + let promiseExtension = AddonTestUtils.promiseWebExtensionStartup(id); + await installBuiltinExtension(extensionData); + let extv1 = await promiseExtension; + + // Prepare an update for the extension. + extensionData.manifest.version = "2.0"; + let xpi = AddonTestUtils.createTempWebExtensionFile(extensionData); + let install = await AddonManager.getInstallForFile(xpi); + + // Install the update and wait for the onUpdateAvailable event to complete. + let promiseUpdate = new Promise(resolve => + extv1.once("test-message", (kind, msg) => { + if (msg == "postponed") { + resolve(); + } + }) + ); + await Promise.all([promisePostponeInstall(install), promiseUpdate]); + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + ExtensionParent._resetStartupPromises(); + let extension = ExtensionTestUtils.expectExtension(id); + await promiseStartupManager(); + await extension.awaitStartup(); + let events = trackEvents(extension); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: true, + delayedStart: true, + request: true, + }); + + await extension.unload(); + + // remove the builtin addon which will have restarted now. + let addon = await AddonManager.getAddonByID(id); + await addon.uninstall(); + + await promiseShutdownManager(); + } +); + +// Tests that moving permission to optional during a staged upgrade retains permission +// and that the persistent listeners are used as expected. +add_task(async function test_persistent_listener_after_staged_upgrade() { + AddonManager.checkUpdateSecurity = false; + let id = "persistent-staged-upgrade@test"; + + // register an update file. + AddonTestUtils.registerJSON(server, "/test_update.json", { + addons: { + "persistent-staged-upgrade@test": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_restart.xpi", + }, + ], + }, + }, + }); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + applications: { + gecko: { id, update_url: `http://example.com/test_update.json` }, + }, + permissions: ["http://example.com/"], + optional_permissions: ["webRequest"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details && details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + } + }); + }, + }; + + // Prepare the update first. + server.registerFile( + `/addons/test_settings_staged_restart.xpi`, + AddonTestUtils.createTempWebExtensionFile(extensionData) + ); + + // Prepare the extension that will be updated. + extensionData.manifest.version = "1.0"; + extensionData.manifest.permissions = ["webRequest", "http://example.com/"]; + delete extensionData.manifest.optional_permissions; + + await promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + ok(true, "Initial version received webRequest event"); + + let addon = await AddonManager.getAddonByID(id); + Assert.equal(addon.version, "1.0", "1.0 is loaded"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal( + install.state, + AddonManager.STATE_POSTPONED, + "update is staged for install" + ); + await extension.awaitMessage("delay"); + + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + ExtensionParent._resetStartupPromises(); + await promiseStartupManager(); + await extension.awaitStartup(); + let events = trackEvents(extension); + + // Verify webRequest permission. + let policy = WebExtensionPolicy.getByID(id); + ok(policy.hasPermission("webRequest"), "addon webRequest permission added"); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: true, + delayedStart: true, + request: true, + }); + + await extension.unload(); + await promiseShutdownManager(); + AddonManager.checkUpdateSecurity = true; +}); + +// Tests that removing the permission releases the persistent listener. +add_task(async function test_persistent_listener_after_permission_removal() { + let id = "persistent-staged-remove@test"; + + // register an update file. + AddonTestUtils.registerJSON(server, "/test_remove.json", { + addons: { + "persistent-staged-remove@test": { + updates: [ + { + version: "2.0", + update_link: + "http://example.com/addons/test_settings_staged_remove.xpi", + }, + ], + }, + }, + }); + + let extensionData = { + useAddonManager: "permanent", + manifest: { + version: "2.0", + applications: { + gecko: { id, update_url: `http://example.com/test_remove.json` }, + }, + permissions: ["tabs", "http://example.com/"], + }, + + background() { + browser.test.sendMessage("loaded"); + }, + }; + + // Prepare the update first. + server.registerFile( + `/addons/test_settings_staged_remove.xpi`, + AddonTestUtils.createTempWebExtensionFile(extensionData) + ); + + await promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + applications: { + gecko: { id, update_url: `http://example.com/test_remove.json` }, + }, + permissions: ["webRequest", "http://example.com/"], + }, + + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("got-request"); + }, + { urls: ["http://example.com/data/file_sample.html"] } + ); + // Force a staged updated. + browser.runtime.onUpdateAvailable.addListener(async details => { + if (details && details.version) { + // This should be the version of the pending update. + browser.test.assertEq("2.0", details.version, "correct version"); + browser.test.sendMessage("delay"); + } + }); + }, + }); + + await extension.startup(); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + await extension.awaitMessage("got-request"); + ok(true, "Initial version received webRequest event"); + + let addon = await AddonManager.getAddonByID(id); + Assert.equal(addon.version, "1.0", "1.0 is loaded"); + + let update = await AddonTestUtils.promiseFindAddonUpdates(addon); + let install = update.updateAvailable; + Assert.ok(install, `install is available ${update.error}`); + + await AddonTestUtils.promiseCompleteAllInstalls([install]); + + Assert.equal( + install.state, + AddonManager.STATE_POSTPONED, + "update is staged for install" + ); + await extension.awaitMessage("delay"); + + await promiseShutdownManager(); + + // restarting allows upgrade to proceed + await promiseStartupManager(); + let events = trackEvents(extension); + await extension.awaitStartup(); + + // Verify webRequest permission. + let policy = WebExtensionPolicy.getByID(id); + ok( + !policy.hasPermission("webRequest"), + "addon webRequest permission removed" + ); + + await ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + await testPersistentRequestStartup(extension, events, { + background: false, + delayedStart: false, + request: false, + }); + + Services.obs.notifyObservers(null, "sessionstore-windows-restored"); + + await extension.awaitMessage("loaded"); + ok(true, "Background page loaded"); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js new file mode 100644 index 0000000000..c8c18fcf19 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_startup_StreamFilter.js @@ -0,0 +1,84 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +let { + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +Services.prefs.setBoolPref( + "extensions.webextensions.background-delayed-startup", + true +); + +// Test that a blocking listener that uses filterResponseData() works +// properly (i.e., that the delayed call to registerTraceableChannel +// works properly). +add_task(async function test_StreamFilter_at_restart() { + const DATA = `<!DOCTYPE html> +<html> +<body> + <h1>This is a modified page</h1> +</body> +</html>`; + + function background(data) { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstop = () => { + let encoded = new TextEncoder("utf-8").encode(data); + filter.write(encoded); + filter.close(); + }; + }, + { urls: ["http://example.com/data/file_sample.html"] }, + ["blocking"] + ); + } + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background: `(${background})(${uneval(DATA)})`, + }); + + await extension.startup(); + + await promiseRestartManager(); + await extension.awaitStartup(); + + let dataPromise = ExtensionTestUtils.fetch( + "http://example.com/", + "http://example.com/data/file_sample.html" + ); + + Services.obs.notifyObservers(null, "browser-delayed-startup-finished"); + let data = await dataPromise; + + equal( + data, + DATA, + "Stream filter was properly installed for a load during startup" + ); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js new file mode 100644 index 0000000000..296bee3685 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_style_cache.js @@ -0,0 +1,49 @@ +"use strict"; + +const BASE = "http://example.com/data/"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_stylesheet_cache() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + const SHEET_URI = "http://example.com/data/file_stylesheet_cache.css"; + let firstFound = false; + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + details.url, + firstFound ? SHEET_URI + "?2" : SHEET_URI + ); + firstFound = true; + browser.test.sendMessage("stylesheet found"); + }, + { urls: ["<all_urls>"], types: ["stylesheet"] }, + ["blocking"] + ); + }, + }); + + await extension.startup(); + + let cp = await ExtensionTestUtils.loadContentPage( + BASE + "file_stylesheet_cache.html" + ); + + await extension.awaitMessage("stylesheet found"); + + // Need to use the same ContentPage so that the remote process the page ends + // up in is the same. + await cp.loadURL(BASE + "file_stylesheet_cache_2.html"); + + await extension.awaitMessage("stylesheet found"); + + await cp.close(); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js new file mode 100644 index 0000000000..48505c9a1b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_suspend.js @@ -0,0 +1,294 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +const BASE_URL = "http://example.com"; +const FETCH_ORIGIN = "http://example.com/dummy"; + +server.registerPathHandler("/return_headers.sjs", (request, response) => { + response.setHeader("Content-Type", "text/plain", false); + + let headers = {}; + for (let { data: header } of request.headers) { + headers[header.toLowerCase()] = request.getHeader(header); + } + + response.write(JSON.stringify(headers)); +}); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_suspend() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + // Make sure that returning undefined or a promise that resolves to + // undefined does not break later handlers. + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + return Promise.resolve(); + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + let requestHeaders = details.requestHeaders.concat({ + name: "Foo", + value: "Bar", + }); + + return new Promise(resolve => { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(resolve, 500); + }).then(() => { + return { requestHeaders }; + }); + }, + { urls: ["<all_urls>"] }, + ["blocking", "requestHeaders"] + ); + }, + }); + + await extension.startup(); + + let headers = JSON.parse( + await ExtensionTestUtils.fetch( + FETCH_ORIGIN, + `${BASE_URL}/return_headers.sjs` + ) + ); + + equal( + headers.foo, + "Bar", + "Request header was correctly set on suspended request" + ); + + await extension.unload(); +}); + +// Test that requests that were canceled while suspended for a blocking +// listener are correctly resumed. +add_task(async function test_error_resume() { + let observer = channel => { + if ( + channel instanceof Ci.nsIHttpChannel && + channel.URI.spec === "http://example.com/dummy" + ) { + Services.obs.removeObserver(observer, "http-on-before-connect"); + + // Wait until the next tick to make sure this runs after WebRequest observers. + Promise.resolve().then(() => { + channel.cancel(Cr.NS_BINDING_ABORTED); + }); + } + }; + + Services.obs.addObserver(observer, "http-on-before-connect"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + browser.webRequest.onBeforeSendHeaders.addListener( + details => { + browser.test.log(`onBeforeSendHeaders({url: ${details.url}})`); + + if (details.url === "http://example.com/dummy") { + browser.test.sendMessage("got-before-send-headers"); + } + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.log(`onErrorOccurred({url: ${details.url}})`); + + if (details.url === "http://example.com/dummy") { + browser.test.sendMessage("got-error-occurred"); + } + }, + { urls: ["<all_urls>"] } + ); + }, + }); + + await extension.startup(); + + try { + await ExtensionTestUtils.fetch(FETCH_ORIGIN, `${BASE_URL}/dummy`); + ok(false, "Fetch should have failed."); + } catch (e) { + ok(true, "Got expected error."); + } + + await extension.awaitMessage("got-before-send-headers"); + await extension.awaitMessage("got-error-occurred"); + + // Wait for the next tick so the onErrorRecurred response can be + // processed before shutting down the extension. + await new Promise(resolve => executeSoon(resolve)); + + await extension.unload(); +}); + +// Test that response header modifications take effect before onStartRequest fires. +add_task(async function test_set_responseHeaders() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "http://example.com/"], + }, + + background() { + browser.webRequest.onHeadersReceived.addListener( + details => { + browser.test.log(`onHeadersReceived({url: ${details.url}})`); + + details.responseHeaders.push({ name: "foo", value: "bar" }); + + return { responseHeaders: details.responseHeaders }; + }, + { urls: ["http://example.com/?modify_headers"] }, + ["blocking", "responseHeaders"] + ); + }, + }); + + await extension.startup(); + + await new Promise(resolve => setTimeout(resolve, 0)); + + let resolveHeaderPromise; + let headerPromise = new Promise(resolve => { + resolveHeaderPromise = resolve; + }); + { + const { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + + let ssm = Services.scriptSecurityManager; + + let channel = NetUtil.newChannel({ + uri: "http://example.com/?modify_headers", + loadingPrincipal: ssm.createContentPrincipalFromOrigin( + "http://example.com" + ), + contentPolicyType: Ci.nsIContentPolicy.TYPE_XMLHTTPREQUEST, + securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + }); + + channel.asyncOpen({ + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onStartRequest(request) { + request.QueryInterface(Ci.nsIHttpChannel); + + try { + resolveHeaderPromise(request.getResponseHeader("foo")); + } catch (e) { + resolveHeaderPromise(null); + } + request.cancel(Cr.NS_BINDING_ABORTED); + }, + + onStopRequest() {}, + + onDataAvailable() { + throw new Components.Exception("", Cr.NS_ERROR_FAILURE); + }, + }); + } + + let headerValue = await headerPromise; + equal(headerValue, "bar", "Expected Foo header value"); + + await extension.unload(); +}); + +// Test that exceptions raised from a blocking webRequest listener that returns +// a promise are logged as expected. +add_task(async function test_logged_error_on_promise_result() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", `${BASE_URL}/`], + }, + + background() { + async function onBeforeRequest() { + throw new Error("Expected webRequest exception from a promise result"); + } + + let exceptionRaised = false; + + browser.webRequest.onBeforeRequest.addListener( + () => { + if (exceptionRaised) { + return; + } + + // We only need to raise the exception once. + exceptionRaised = true; + return onBeforeRequest(); + }, + { + urls: ["http://example.com/*"], + types: ["main_frame"], + }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + () => { + browser.test.sendMessage("web-request-event-received"); + }, + { + urls: ["http://example.com/*"], + types: ["main_frame"], + }, + ["blocking"] + ); + }, + }); + + let { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await extension.awaitMessage("web-request-event-received"); + await contentPage.close(); + }); + + ok( + messages.some(msg => + /Expected webRequest exception from a promise result/.test(msg.message) + ), + "Got expected console message" + ); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js new file mode 100644 index 0000000000..9c2296c7da --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_urlclassification.js @@ -0,0 +1,33 @@ +"use strict"; + +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +/** + * If this test fails, likely nsIClassifiedChannel has added or changed a + * CLASSIFIED_* flag. Those changes must be in sync with + * ChannelWrapper.webidl/cpp and the web_request.json schema file. + */ +add_task(async function test_webrequest_url_classification_enum() { + // use normalizeManifest to get the schema loaded. + await ExtensionTestUtils.normalizeManifest({ permissions: ["webRequest"] }); + + let ns = Schemas.getNamespace("webRequest"); + let schema_enum = ns.get("UrlClassificationFlags").enumeration; + ok( + !!schema_enum.length, + `UrlClassificationFlags: ${JSON.stringify(schema_enum)}` + ); + + let prefix = /^(?:CLASSIFIED_)/; + let entries = 0; + for (let c of Object.keys(Ci.nsIClassifiedChannel).filter(name => + prefix.test(name) + )) { + let entry = c.replace(prefix, "").toLowerCase(); + if (!entry.startsWith("socialtracking")) { + ok(schema_enum.includes(entry), `schema ${entry} is in IDL`); + entries++; + } + } + equal(schema_enum.length, entries, "same number of entries"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js new file mode 100644 index 0000000000..9710aa5990 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_userContextId.js @@ -0,0 +1,41 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_userContextId_webrequest() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + async details => { + browser.test.assertEq( + details.cookieStoreId, + "firefox-container-2", + "cookieStoreId is set" + ); + browser.test.notifyPass("webRequest"); + }, + { urls: ["<all_urls>"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 2 } + ); + await extension.awaitFinish("webRequest"); + + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js new file mode 100644 index 0000000000..35b713e59b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource.js @@ -0,0 +1,95 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_webRequest_viewsource() { + function background(serverPort) { + browser.proxy.onRequest.addListener( + details => { + if (details.url === `http://example.com:${serverPort}/dummy`) { + browser.test.assertTrue( + true, + "viewsource protocol worked in proxy request" + ); + browser.test.sendMessage("proxied"); + } + }, + { urls: ["<all_urls>"] } + ); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + `http://example.com:${serverPort}/redirect`, + details.url, + "viewsource protocol worked in webRequest" + ); + browser.test.sendMessage("viewed"); + return { redirectUrl: `http://example.com:${serverPort}/dummy` }; + }, + { urls: ["http://example.com/redirect"] }, + ["blocking"] + ); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + `http://example.com:${serverPort}/dummy`, + details.url, + "viewsource protocol worked in webRequest" + ); + browser.test.sendMessage("redirected"); + return { cancel: true }; + }, + { urls: ["http://example.com/dummy"] }, + ["blocking"] + ); + + browser.webRequest.onCompleted.addListener( + details => { + // If cancel fails we get onCompleted. + browser.test.fail("onCompleted received"); + }, + { urls: ["http://example.com/dummy"] } + ); + + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.assertEq( + details.error, + "NS_ERROR_ABORT", + "request cancelled" + ); + browser.test.sendMessage("cancelled"); + }, + { urls: ["http://example.com/dummy"] } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "webRequest", "webRequestBlocking", "<all_urls>"], + }, + background: `(${background})(${server.identity.primaryPort})`, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + `view-source:http://example.com:${server.identity.primaryPort}/redirect` + ); + + await Promise.all([ + extension.awaitMessage("proxied"), + extension.awaitMessage("viewed"), + extension.awaitMessage("redirected"), + extension.awaitMessage("cancelled"), + ]); + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js new file mode 100644 index 0000000000..ccb46eb4db --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_viewsource_StreamFilter.js @@ -0,0 +1,144 @@ +"use strict"; + +const server = createHttpServer(); +const BASE_URL = `http://127.0.0.1:${server.identity.primaryPort}`; + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +server.registerPathHandler("/redir", (request, response) => { + response.setStatusLine(request.httpVersion, 303, "See Other"); + response.setHeader("Location", `${BASE_URL}/dummy`); +}); + +async function testViewSource(viewSourceUrl) { + function background(BASE_URL) { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq(`${BASE_URL}/dummy`, details.url, "expected URL"); + browser.test.assertEq("main_frame", details.type, "details.type"); + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.write(new TextEncoder().encode("PREFIX_")); + }; + filter.ondata = event => { + filter.write(event.data); + }; + filter.onstop = () => { + filter.write(new TextEncoder().encode("_SUFFIX")); + filter.disconnect(); + browser.test.notifyPass("filter_end"); + }; + filter.onerror = () => { + browser.test.fail(`Unexpected error: ${filter.error}`); + browser.test.notifyFail("filter_end"); + }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq(`${BASE_URL}/redir`, details.url, "Got redirect"); + + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstop = () => { + filter.disconnect(); + browser.test.fail("Unexpected onstop for redirect"); + browser.test.sendMessage("redirect_done"); + }; + filter.onerror = () => { + browser.test.assertEq( + // TODO bug 1683862: must be "Channel redirected", but it is not + // because document requests are handled differently compared to + // other requests, see the comment at the top of + // test_ext_webRequest_redirect_StreamFilter.js. + "Invalid request ID", + filter.error, + "Expected error in filter.onerror" + ); + browser.test.sendMessage("redirect_done"); + }; + }, + { urls: ["*://*/redir"] }, + ["blocking"] + ); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background: `(${background})(${JSON.stringify(BASE_URL)})`, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage(viewSourceUrl); + if (viewSourceUrl.includes("/redir")) { + info("Awaiting observed completion of redirection request"); + await extension.awaitMessage("redirect_done"); + } + info("Awaiting completion of StreamFilter on request"); + await extension.awaitFinish("filter_end"); + let contentText = await contentPage.spawn(null, () => { + return this.content.document.body.textContent; + }); + equal(contentText, "PREFIX_ok_SUFFIX", "view-source response body"); + await contentPage.close(); + await extension.unload(); +} + +add_task(async function test_StreamFilter_viewsource() { + await testViewSource(`view-source:${BASE_URL}/dummy`); +}); + +add_task(async function test_StreamFilter_viewsource_redirect_target() { + await testViewSource(`view-source:${BASE_URL}/redir`); +}); + +// Sanity check: nothing bad happens if the underlying response is aborted. +add_task(async function test_StreamFilter_viewsource_cancel() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "*://*/*"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + let filter = browser.webRequest.filterResponseData(details.requestId); + filter.onstart = () => { + filter.disconnect(); + browser.test.fail("Unexpected filter.onstart"); + browser.test.notifyFail("filter_end"); + }; + filter.onerror = () => { + browser.test.assertEq("Invalid request ID", filter.error, "Error?"); + browser.test.notifyPass("filter_end"); + }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + browser.webRequest.onHeadersReceived.addListener( + () => { + browser.test.log("Intentionally canceling view-source request"); + return { cancel: true }; + }, + { urls: ["*://*/dummy"] }, + ["blocking"] + ); + }, + }); + await extension.startup(); + let contentPage = await ExtensionTestUtils.loadContentPage( + `${BASE_URL}/dummy` + ); + await extension.awaitFinish("filter_end"); + let contentText = await contentPage.spawn(null, () => { + return this.content.document.body.textContent; + }); + equal(contentText, "", "view-source request should have been canceled"); + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js new file mode 100644 index 0000000000..7e34d2b0b3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_webRequest_webSocket.js @@ -0,0 +1,55 @@ +"use strict"; + +const HOSTS = new Set(["example.com"]); + +const server = createHttpServer({ hosts: HOSTS }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.write("ok"); +}); + +add_task(async function test_webSocket() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webRequest", "webRequestBlocking", "<all_urls>"], + }, + background() { + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.assertEq( + "ws:", + new URL(details.url).protocol, + "ws protocol worked" + ); + browser.test.notifyPass("websocket"); + }, + { urls: ["ws://example.com/*"] }, + ["blocking"] + ); + + browser.test.onMessage.addListener(msg => { + let ws = new WebSocket("ws://example.com/dummy"); + ws.onopen = e => { + ws.send("data"); + }; + ws.onclose = e => {}; + ws.onerror = e => {}; + ws.onmessage = e => { + ws.close(); + }; + }); + browser.test.sendMessage("ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("go"); + await extension.awaitFinish("websocket"); + + // Wait until the next tick so that listener responses are processed + // before we unload. + await new Promise(executeSoon); + + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js new file mode 100644 index 0000000000..a1a387b5a4 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_web_accessible_resources.js @@ -0,0 +1,150 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com", "example.org"] }); +server.registerDirectory("/data/", do_get_file("data")); + +let image = atob( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" + + "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII=" +); +const IMAGE_ARRAYBUFFER = Uint8Array.from(image, byte => byte.charCodeAt(0)) + .buffer; + +async function testImageLoading(src, expectedAction) { + let imageLoadingPromise = new Promise((resolve, reject) => { + let cleanupListeners; + let testImage = document.createElement("img"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testImage.wrappedJSObject.setAttribute("src", src); + + let loadListener = () => { + cleanupListeners(); + resolve(expectedAction === "loaded"); + }; + + let errorListener = () => { + cleanupListeners(); + resolve(expectedAction === "blocked"); + }; + + cleanupListeners = () => { + testImage.removeEventListener("load", loadListener); + testImage.removeEventListener("error", errorListener); + }; + + testImage.addEventListener("load", loadListener); + testImage.addEventListener("error", errorListener); + + document.body.appendChild(testImage); + }); + + let success = await imageLoadingPromise; + browser.runtime.sendMessage({ + name: "image-loading", + expectedAction, + success, + }); +} + +add_task(async function test_web_accessible_resources_csp() { + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg.name === "image-loading") { + browser.test.assertTrue(msg.success, `Image was ${msg.expectedAction}`); + browser.test.sendMessage(`image-${msg.expectedAction}`); + } else { + browser.test.sendMessage(msg); + } + }); + + browser.test.sendMessage("background-ready"); + } + + function content() { + window.addEventListener("message", function rcv(event) { + browser.runtime.sendMessage("script-ran"); + window.removeEventListener("message", rcv); + }); + + testImageLoading(browser.extension.getURL("image.png"), "loaded"); + + let testScriptElement = document.createElement("script"); + // Set the src via wrappedJSObject so the load is triggered with the + // content page's principal rather than ours. + testScriptElement.wrappedJSObject.setAttribute( + "src", + browser.extension.getURL("test_script.js") + ); + document.head.appendChild(testScriptElement); + browser.runtime.sendMessage("script-loaded"); + } + + function testScript() { + window.postMessage("test-script-loaded", "*"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*/file_csp.html"], + run_at: "document_end", + js: ["content_script_helper.js", "content_script.js"], + }, + ], + web_accessible_resources: ["image.png", "test_script.js"], + }, + background, + files: { + "content_script_helper.js": `${testImageLoading}`, + "content_script.js": content, + "test_script.js": testScript, + "image.png": IMAGE_ARRAYBUFFER, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("background-ready"), + ]); + + let page = await ExtensionTestUtils.loadContentPage( + `http://example.com/data/file_sample.html` + ); + await page.spawn(null, () => { + let { Services } = ChromeUtils.import( + "resource://gre/modules/Services.jsm" + ); + this.obs = { + events: [], + observe(subject, topic, data) { + this.events.push(subject.QueryInterface(Ci.nsIURI).spec); + }, + done() { + Services.obs.removeObserver(this, "csp-on-violate-policy"); + return this.events; + }, + }; + Services.obs.addObserver(this.obs, "csp-on-violate-policy"); + content.location.href = "http://example.com/data/file_csp.html"; + }); + + await Promise.all([ + extension.awaitMessage("image-loaded"), + extension.awaitMessage("script-loaded"), + extension.awaitMessage("script-ran"), + ]); + + let events = await page.spawn(null, () => this.obs.done()); + equal(events.length, 2, "Two items were rejected by CSP"); + for (let url of events) { + ok( + url.includes("file_image_bad.png") || url.includes("file_script_bad.js"), + `Expected file: ${url} rejected by CSP` + ); + } + + await page.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js new file mode 100644 index 0000000000..640e5be0de --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_xhr_capabilities.js @@ -0,0 +1,72 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function test_xhr_capabilities() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let xhr = new XMLHttpRequest(); + xhr.open("GET", browser.extension.getURL("bad.xml")); + + browser.test.sendMessage("result", { + name: "Background script XHRs should not be privileged", + result: xhr.channel === undefined, + }); + + xhr.onload = () => { + browser.test.sendMessage("result", { + name: "Background script XHRs should not yield <parsererrors>", + result: xhr.responseXML === null, + }); + }; + xhr.send(); + }, + + manifest: { + content_scripts: [ + { + matches: ["http://example.com/data/file_sample.html"], + js: ["content_script.js"], + }, + ], + web_accessible_resources: ["bad.xml"], + }, + + files: { + "bad.xml": "<xml", + "content_script.js"() { + let xhr = new XMLHttpRequest(); + xhr.open("GET", browser.extension.getURL("bad.xml")); + + browser.test.sendMessage("result", { + name: "Content script XHRs should not be privileged", + result: xhr.channel === undefined, + }); + + xhr.onload = () => { + browser.test.sendMessage("result", { + name: "Content script XHRs should not yield <parsererrors>", + result: xhr.responseXML === null, + }); + }; + xhr.send(); + }, + }, + }); + + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + + // We expect four test results from the content/background scripts. + for (let i = 0; i < 4; ++i) { + let result = await extension.awaitMessage("result"); + ok(result.result, result.name); + } + + await contentPage.close(); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js new file mode 100644 index 0000000000..9e168107ff --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_extension_permissions_migration.js @@ -0,0 +1,99 @@ +"use strict"; + +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); +const { ExtensionPermissions } = ChromeUtils.import( + "resource://gre/modules/ExtensionPermissions.jsm" +); + +add_task(async function setup() { + // Bug 1646182: Force ExtensionPermissions to run in rkv mode, because this + // test does not make sense with the legacy method (which will be removed in + // the above bug). + await ExtensionPermissions._uninit(); +}); + +const GOOD_JSON_FILE = { + "wikipedia@search.mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, + "amazon@search.mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, + "doh-rollout@mozilla.org": { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }, +}; + +const BAD_JSON_FILE = { + "test@example.org": "what", +}; + +const BAD_FILE = "what is this { } {"; + +const gOldSettingsJSON = do_get_profile().clone(); +gOldSettingsJSON.append("extension-preferences.json"); + +async function test_file(json, extensionIds, expected, fileDeleted) { + await ExtensionPermissions._resetVersion(); + await ExtensionPermissions._uninit(); + + await OS.File.writeAtomic(gOldSettingsJSON.path, json, { + encoding: "utf-8", + }); + + for (let extensionId of extensionIds) { + let permissions = await ExtensionPermissions.get(extensionId); + Assert.deepEqual(permissions, expected, "permissions match"); + } + + Assert.equal( + await OS.File.exists(gOldSettingsJSON.path), + !fileDeleted, + "old file was deleted" + ); +} + +add_task(async function test_migrate_good_json() { + let expected = { + permissions: ["internal:privateBrowsingAllowed"], + origins: [], + }; + + await test_file( + JSON.stringify(GOOD_JSON_FILE), + [ + "wikipedia@search.mozilla.org", + "amazon@search.mozilla.org", + "doh-rollout@mozilla.org", + ], + expected, + /* fileDeleted */ true + ); +}); + +add_task(async function test_migrate_bad_json() { + let expected = { permissions: [], origins: [] }; + + await test_file( + BAD_FILE, + ["test@example.org"], + expected, + /* fileDeleted */ false + ); + await OS.File.remove(gOldSettingsJSON.path); +}); + +add_task(async function test_migrate_bad_file() { + let expected = { permissions: [], origins: [] }; + + await test_file( + JSON.stringify(BAD_JSON_FILE), + ["test2@example.org"], + expected, + /* fileDeleted */ false + ); + await OS.File.remove(gOldSettingsJSON.path); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js new file mode 100644 index 0000000000..54a24233e2 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_load_all_api_modules.js @@ -0,0 +1,172 @@ +"use strict"; + +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; + +const CATEGORY_EXTENSION_MODULES = "webextension-modules"; +const CATEGORY_EXTENSION_SCHEMAS = "webextension-schemas"; +const CATEGORY_EXTENSION_SCRIPTS = "webextension-scripts"; + +const CATEGORY_EXTENSION_SCRIPTS_ADDON = "webextension-scripts-addon"; +const CATEGORY_EXTENSION_SCRIPTS_CONTENT = "webextension-scripts-content"; +const CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS = "webextension-scripts-devtools"; + +let schemaURLs = new Set(); +schemaURLs.add("chrome://extensions/content/schemas/experiments.json"); + +// Helper class used to load the API modules similarly to the apiManager +// defined in ExtensionParent.jsm. +class FakeAPIManager extends ExtensionCommon.SchemaAPIManager { + constructor(processType = "main") { + super(processType, Schemas); + this.initialized = false; + } + + getModuleJSONURLs() { + return Array.from( + Services.catMan.enumerateCategory(CATEGORY_EXTENSION_MODULES), + ({ value }) => value + ); + } + + async lazyInit() { + if (this.initialized) { + return; + } + + this.initialized = true; + + let modulesPromise = this.loadModuleJSON(this.getModuleJSONURLs()); + + let scriptURLs = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCRIPTS + )) { + scriptURLs.push(value); + } + + let scripts = await Promise.all( + scriptURLs.map(url => ChromeUtils.compileScript(url)) + ); + + this.initModuleData(await modulesPromise); + + this.initGlobal(); + for (let script of scripts) { + script.executeInGlobal(this.global); + } + + // Load order matters here. The base manifest defines types which are + // extended by other schemas, so needs to be loaded first. + await Schemas.load(BASE_SCHEMA).then(() => { + let promises = []; + for (let { value } of Services.catMan.enumerateCategory( + CATEGORY_EXTENSION_SCHEMAS + )) { + promises.push(Schemas.load(value)); + } + for (let [url, { content }] of this.schemaURLs) { + promises.push(Schemas.load(url, content)); + } + for (let url of schemaURLs) { + promises.push(Schemas.load(url)); + } + return Promise.all(promises).then(() => { + Schemas.updateSharedSchemas(); + }); + }); + } + + async loadAllModules(reverseOrder = false) { + await this.lazyInit(); + + let apiModuleNames = Array.from(this.modules.keys()) + .filter(moduleName => { + let moduleDesc = this.modules.get(moduleName); + return moduleDesc && !!moduleDesc.url; + }) + .sort(); + + apiModuleNames = reverseOrder ? apiModuleNames.reverse() : apiModuleNames; + + for (let apiModule of apiModuleNames) { + info( + `Loading apiModule ${apiModule}: ${this.modules.get(apiModule).url}` + ); + await this.asyncLoadModule(apiModule); + } + } +} + +// Specialized helper class used to test loading "child process" modules (similarly to the +// SchemaAPIManagers sub-classes defined in ExtensionPageChild.jsm and ExtensionContent.jsm). +class FakeChildProcessAPIManager extends FakeAPIManager { + constructor({ processType, categoryScripts }) { + super(processType, Schemas); + + this.categoryScripts = categoryScripts; + } + + async lazyInit() { + if (!this.initialized) { + this.initialized = true; + this.initGlobal(); + for (let { value } of Services.catMan.enumerateCategory( + this.categoryScripts + )) { + await this.loadScript(value); + } + } + } +} + +async function test_loading_api_modules(createAPIManager) { + let fakeAPIManager; + + info("Load API modules in alphabetic order"); + + fakeAPIManager = createAPIManager(); + await fakeAPIManager.loadAllModules(); + + info("Load API modules in reverse order"); + + fakeAPIManager = createAPIManager(); + await fakeAPIManager.loadAllModules(true); +} + +add_task(function test_loading_main_process_api_modules() { + return test_loading_api_modules(() => { + return new FakeAPIManager(); + }); +}); + +add_task(function test_loading_extension_process_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "addon", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_ADDON, + }); + }); +}); + +add_task(function test_loading_devtools_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "devtools", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_DEVTOOLS, + }); + }); +}); + +add_task(async function test_loading_content_process_modules() { + return test_loading_api_modules(() => { + return new FakeChildProcessAPIManager({ + processType: "content", + categoryScripts: CATEGORY_EXTENSION_SCRIPTS_CONTENT, + }); + }); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_converter.js b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js new file mode 100644 index 0000000000..6729639cc9 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_locale_converter.js @@ -0,0 +1,146 @@ +"use strict"; + +const convService = Cc["@mozilla.org/streamConverters;1"].getService( + Ci.nsIStreamConverterService +); + +const UUID = "72b61ee3-aceb-476c-be1b-0822b036c9f1"; +const ADDON_ID = "test@web.extension"; +const URI = NetUtil.newURI(`moz-extension://${UUID}/file.css`); + +const FROM_TYPE = "application/vnd.mozilla.webext.unlocalized"; +const TO_TYPE = "text/css"; + +function StringStream(string) { + let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance( + Ci.nsIStringInputStream + ); + + stream.data = string; + return stream; +} + +// Initialize the policy service with a stub localizer for our +// add-on ID. +add_task(async function init() { + let policy = new WebExtensionPolicy({ + id: ADDON_ID, + mozExtensionHostname: UUID, + baseURL: "file:///", + + allowedOrigins: new MatchPatternSet([]), + + localizeCallback(string) { + return string.replace(/__MSG_(.*?)__/g, "<localized-$1>"); + }, + }); + + policy.active = true; + + registerCleanupFunction(() => { + policy.active = false; + }); +}); + +// Test that the synchronous converter works as expected with a +// simple string. +add_task(async function testSynchronousConvert() { + let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz"); + + let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI); + + let result = NetUtil.readInputStreamToString( + resultStream, + resultStream.available() + ); + + equal(result, "Foo <localized-xxx> bar <localized-yyy> baz"); +}); + +// Test that the asynchronous converter works as expected with input +// split into multiple chunks, and a boundary in the middle of a +// replacement token. +add_task(async function testAsyncConvert() { + let listener; + let awaitResult = new Promise((resolve, reject) => { + listener = { + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + + onDataAvailable(request, inputStream, offset, count) { + this.resultParts.push( + NetUtil.readInputStreamToString(inputStream, count) + ); + }, + + onStartRequest() { + ok(!("resultParts" in this)); + this.resultParts = []; + }, + + onStopRequest(request, context, statusCode) { + if (!Components.isSuccessCode(statusCode)) { + reject(new Error(statusCode)); + } + + resolve(this.resultParts.join("\n")); + }, + }; + }); + + let parts = ["Foo __MSG_x", "xx__ bar __MSG_yyy__ baz"]; + + let converter = convService.asyncConvertData( + FROM_TYPE, + TO_TYPE, + listener, + URI + ); + converter.onStartRequest(null, null); + + for (let part of parts) { + converter.onDataAvailable(null, StringStream(part), 0, part.length); + } + + converter.onStopRequest(null, null, Cr.NS_OK); + + let result = await awaitResult; + equal(result, "Foo <localized-xxx> bar <localized-yyy> baz"); +}); + +// Test that attempting to initialize a converter with the URI of a +// nonexistent WebExtension fails. +add_task(async function testInvalidUUID() { + let uri = NetUtil.newURI( + "moz-extension://eb4f3be8-41c9-4970-aa6d-b84d1ecc02b2/file.css" + ); + let stream = StringStream("Foo __MSG_xxx__ bar __MSG_yyy__ baz"); + + // Assert.throws raise a TypeError exception when the expected param + // is an arrow function. (See Bug 1237961 for rationale) + let expectInvalidContextException = function(e) { + return e.result === Cr.NS_ERROR_INVALID_ARG && /Invalid context/.test(e); + }; + + Assert.throws(() => { + convService.convert(stream, FROM_TYPE, TO_TYPE, uri); + }, expectInvalidContextException); + + Assert.throws(() => { + let listener = { + QueryInterface: ChromeUtils.generateQI(["nsIStreamListener"]), + }; + + convService.asyncConvertData(FROM_TYPE, TO_TYPE, listener, uri); + }, expectInvalidContextException); +}); + +// Test that an empty stream does not throw an NS_ERROR_ILLEGAL_VALUE. +add_task(async function testEmptyStream() { + let stream = StringStream(""); + let resultStream = convService.convert(stream, FROM_TYPE, TO_TYPE, URI); + equal( + resultStream.available(), + 0, + "Size of output stream should match size of input stream" + ); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_locale_data.js b/toolkit/components/extensions/test/xpcshell/test_locale_data.js new file mode 100644 index 0000000000..32155a7c91 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_locale_data.js @@ -0,0 +1,221 @@ +"use strict"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +const { ExtensionData } = ChromeUtils.import( + "resource://gre/modules/Extension.jsm" +); + +async function generateAddon(data) { + let xpi = AddonTestUtils.createTempWebExtensionFile(data); + + let fileURI = Services.io.newFileURI(xpi); + let jarURI = NetUtil.newURI(`jar:${fileURI.spec}!/`); + + let extension = new ExtensionData(jarURI); + await extension.loadManifest(); + + return extension; +} + +add_task(async function testMissingDefaultLocale() { + let extension = await generateAddon({ + files: { + "_locales/en_US/messages.json": {}, + }, + }); + + equal(extension.errors.length, 0, "No errors reported"); + + await extension.initAllLocales(); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes('"default_locale" property is required'), + "Got missing default_locale error" + ); +}); + +add_task(async function testInvalidDefaultLocale() { + let extension = await generateAddon({ + manifest: { + default_locale: "en", + }, + + files: { + "_locales/en_US/messages.json": {}, + }, + }); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en/messages.json" + ), + "Got invalid default_locale error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "Two errors reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes('"default_locale" property must correspond'), + "Got invalid default_locale error" + ); +}); + +add_task(async function testUnexpectedDefaultLocale() { + let extension = await generateAddon({ + manifest: { + default_locale: "en_US", + }, + }); + + equal(extension.errors.length, 1, "One error reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en-US/messages.json" + ), + "Got invalid default_locale error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "One error reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes('"default_locale" property must correspond'), + "Got unexpected default_locale error" + ); +}); + +add_task(async function testInvalidSyntax() { + let extension = await generateAddon({ + manifest: { + default_locale: "en_US", + }, + + files: { + "_locales/en_US/messages.json": + '{foo: {message: "bar", description: "baz"}}', + }, + }); + + equal(extension.errors.length, 1, "No errors reported"); + + info(`Got error: ${extension.errors[0]}`); + + ok( + extension.errors[0].includes( + "Loading locale file _locales/en_US/messages.json: SyntaxError" + ), + "Got syntax error" + ); + + await extension.initAllLocales(); + + equal(extension.errors.length, 2, "One error reported"); + + info(`Got error: ${extension.errors[1]}`); + + ok( + extension.errors[1].includes( + "Loading locale file _locales/en_US/messages.json: SyntaxError" + ), + "Got syntax error" + ); +}); + +add_task(async function testExtractLocalizedManifest() { + let extension = await generateAddon({ + manifest: { + name: "__MSG_extensionName__", + default_locale: "en_US", + icons: { + "16": "__MSG_extensionIcon__", + }, + }, + + files: { + "_locales/en_US/messages.json": `{ + "extensionName": {"message": "foo"}, + "extensionIcon": {"message": "icon-en.png"} + }`, + "_locales/de_DE/messages.json": `{ + "extensionName": {"message": "bar"}, + "extensionIcon": {"message": "icon-de.png"} + }`, + }, + }); + + await extension.loadManifest(); + equal(extension.manifest.name, "foo", "name localized"); + equal(extension.manifest.icons["16"], "icon-en.png", "icons localized"); + + let manifest = await extension.getLocalizedManifest("de-DE"); + ok(extension.localeData.has("de-DE"), "has de_DE locale"); + equal(manifest.name, "bar", "name localized"); + equal(manifest.icons["16"], "icon-de.png", "icons localized"); + + await Assert.rejects( + extension.getLocalizedManifest("xx-XX"), + /does not contain the locale xx-XX/, + "xx-XX does not exist" + ); +}); + +add_task(async function testRestartThenExtractLocalizedManifest() { + await AddonTestUtils.promiseStartupManager(); + + let wrapper = ExtensionTestUtils.loadExtension({ + manifest: { + name: "__MSG_extensionName__", + default_locale: "en_US", + }, + useAddonManager: "permanent", + files: { + "_locales/en_US/messages.json": '{"extensionName": {"message": "foo"}}', + "_locales/de_DE/messages.json": '{"extensionName": {"message": "bar"}}', + }, + }); + + await wrapper.startup(); + + await AddonTestUtils.promiseRestartManager(); + await wrapper.startupPromise; + + let { extension } = wrapper; + let manifest = await extension.getLocalizedManifest("de-DE"); + ok(extension.localeData.has("de-DE"), "has de_DE locale"); + equal(manifest.name, "bar", "name localized"); + + await Assert.rejects( + extension.getLocalizedManifest("xx-XX"), + /does not contain the locale xx-XX/, + "xx-XX does not exist" + ); + + await wrapper.unload(); + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_native_manifests.js b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js new file mode 100644 index 0000000000..ca32517fd5 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_native_manifests.js @@ -0,0 +1,443 @@ +"use strict"; + +const { AsyncShutdown } = ChromeUtils.import( + "resource://gre/modules/AsyncShutdown.jsm" +); +const { ExtensionCommon } = ChromeUtils.import( + "resource://gre/modules/ExtensionCommon.jsm" +); +const { NativeManifests } = ChromeUtils.import( + "resource://gre/modules/NativeManifests.jsm" +); +const { FileUtils } = ChromeUtils.import( + "resource://gre/modules/FileUtils.jsm" +); +const { Schemas } = ChromeUtils.import("resource://gre/modules/Schemas.jsm"); +const { Subprocess, SubprocessImpl } = ChromeUtils.import( + "resource://gre/modules/Subprocess.jsm", + null +); +const { NativeApp } = ChromeUtils.import( + "resource://gre/modules/NativeMessaging.jsm" +); +const { OS } = ChromeUtils.import("resource://gre/modules/osfile.jsm"); + +let registry = null; +if (AppConstants.platform == "win") { + var { MockRegistry } = ChromeUtils.import( + "resource://testing-common/MockRegistry.jsm" + ); + registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); +} + +const REGPATH = "Software\\Mozilla\\NativeMessagingHosts"; + +const BASE_SCHEMA = "chrome://extensions/content/schemas/manifest.json"; + +const TYPE_SLUG = + AppConstants.platform === "linux" + ? "native-messaging-hosts" + : "NativeMessagingHosts"; + +let dir = FileUtils.getDir("TmpD", ["NativeManifests"]); +dir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let userDir = dir.clone(); +userDir.append("user"); +userDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +let globalDir = dir.clone(); +globalDir.append("global"); +globalDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + +OS.File.makeDir(OS.Path.join(userDir.path, TYPE_SLUG)); +OS.File.makeDir(OS.Path.join(globalDir.path, TYPE_SLUG)); + +let dirProvider = { + getFile(property) { + if (property == "XREUserNativeManifests") { + return userDir.clone(); + } else if (property == "XRESysNativeManifests") { + return globalDir.clone(); + } + return null; + }, +}; + +Services.dirsvc.registerProvider(dirProvider); + +registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + dir.remove(true); +}); + +function writeManifest(path, manifest) { + if (typeof manifest != "string") { + manifest = JSON.stringify(manifest); + } + return OS.File.writeAtomic(path, manifest); +} + +let PYTHON; +add_task(async function setup() { + await Schemas.load(BASE_SCHEMA); + + const env = Cc["@mozilla.org/process/environment;1"].getService( + Ci.nsIEnvironment + ); + try { + PYTHON = await Subprocess.pathSearch(env.get("PYTHON")); + } catch (e) { + notEqual( + PYTHON, + null, + `Can't find a suitable python interpreter ${e.message}` + ); + } +}); + +let global = this; + +// Test of NativeManifests.lookupApplication() begin here... +let context = { + extension: { + id: "extension@tests.mozilla.org", + }, + envType: "addon_parent", + url: null, + jsonStringify(...args) { + return JSON.stringify(...args); + }, + cloneScope: global, + logError() {}, + preprocessors: {}, + callOnClose: () => {}, + forgetOnClose: () => {}, +}; + +class MockContext extends ExtensionCommon.BaseContext { + constructor(extensionId) { + let fakeExtension = { id: extensionId }; + super("addon_parent", fakeExtension); + this.sandbox = Cu.Sandbox(global); + } + + get cloneScope() { + return global; + } + + get principal() { + return Cu.getObjectPrincipal(this.sandbox); + } +} + +let templateManifest = { + name: "test", + description: "this is only a test", + path: "/bin/cat", + type: "stdio", + allowed_extensions: ["extension@tests.mozilla.org"], +}; + +function lookupApplication(app, ctx) { + return NativeManifests.lookupManifest("stdio", app, ctx); +} + +add_task(async function test_nonexistent_manifest() { + let result = await lookupApplication("test", context); + equal( + result, + null, + "lookupApplication returns null for non-existent application" + ); +}); + +const USER_TEST_JSON = OS.Path.join(userDir.path, TYPE_SLUG, "test.json"); + +add_task(async function test_nonexistent_manifest_with_registry_entry() { + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + + await OS.File.remove(USER_TEST_JSON); + let { messages, result } = await promiseConsoleOutput(() => + lookupApplication("test", context) + ); + equal( + result, + null, + "lookupApplication returns null for non-existent manifest" + ); + + let noSuchFileErrors = messages.filter(logMessage => + logMessage.message.includes( + "file is referenced in the registry but does not exist" + ) + ); + + if (registry) { + equal( + noSuchFileErrors.length, + 1, + "lookupApplication logs a non-existent manifest file pointed to by the registry" + ); + } else { + equal( + noSuchFileErrors.length, + 0, + "lookupApplication does not log about registry on non-windows platforms" + ); + } +}); + +add_task(async function test_good_manifest() { + await writeManifest(USER_TEST_JSON, templateManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + + let result = await lookupApplication("test", context); + notEqual(result, null, "lookupApplication finds a good manifest"); + equal( + result.path, + USER_TEST_JSON, + "lookupApplication returns the correct path" + ); + deepEqual( + result.manifest, + templateManifest, + "lookupApplication returns the manifest contents" + ); +}); + +add_task(async function test_invalid_json() { + await writeManifest(USER_TEST_JSON, "this is not valid json"); + let result = await lookupApplication("test", context); + equal(result, null, "lookupApplication ignores bad json"); +}); + +add_task(async function test_invalid_name() { + let manifest = Object.assign({}, templateManifest); + manifest.name = "../test"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, "lookupApplication ignores an invalid name"); +}); + +add_task(async function test_name_mismatch() { + let manifest = Object.assign({}, templateManifest); + manifest.name = "not test"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + let what = AppConstants.platform == "win" ? "registry key" : "json filename"; + equal( + result, + null, + `lookupApplication ignores mistmatch between ${what} and name property` + ); +}); + +add_task(async function test_missing_props() { + const PROPS = ["name", "description", "path", "type", "allowed_extensions"]; + for (let prop of PROPS) { + let manifest = Object.assign({}, templateManifest); + delete manifest[prop]; + + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, `lookupApplication ignores missing ${prop}`); + } +}); + +add_task(async function test_invalid_type() { + let manifest = Object.assign({}, templateManifest); + manifest.type = "bogus"; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal(result, null, "lookupApplication ignores invalid type"); +}); + +add_task(async function test_no_allowed_extensions() { + let manifest = Object.assign({}, templateManifest); + manifest.allowed_extensions = []; + await writeManifest(USER_TEST_JSON, manifest); + let result = await lookupApplication("test", context); + equal( + result, + null, + "lookupApplication ignores manifest with no allowed_extensions" + ); +}); + +const GLOBAL_TEST_JSON = OS.Path.join(globalDir.path, TYPE_SLUG, "test.json"); +let globalManifest = Object.assign({}, templateManifest); +globalManifest.description = "This manifest is from the systemwide directory"; + +add_task(async function good_manifest_system_dir() { + await OS.File.remove(USER_TEST_JSON); + await writeManifest(GLOBAL_TEST_JSON, globalManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + null + ); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_LOCAL_MACHINE, + `${REGPATH}\\test`, + "", + GLOBAL_TEST_JSON + ); + } + + let where = + AppConstants.platform == "win" ? "registry location" : "directory"; + let result = await lookupApplication("test", context); + notEqual( + result, + null, + `lookupApplication finds a manifest in the system-wide ${where}` + ); + equal( + result.path, + GLOBAL_TEST_JSON, + `lookupApplication returns path in the system-wide ${where}` + ); + deepEqual( + result.manifest, + globalManifest, + `lookupApplication returns manifest contents from the system-wide ${where}` + ); +}); + +add_task(async function test_user_dir_precedence() { + await writeManifest(USER_TEST_JSON, templateManifest); + if (registry) { + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\test`, + "", + USER_TEST_JSON + ); + } + // global test.json and LOCAL_MACHINE registry key on windows are + // still present from the previous test + + let result = await lookupApplication("test", context); + notEqual( + result, + null, + "lookupApplication finds a manifest when entries exist in both user-specific and system-wide locations" + ); + equal( + result.path, + USER_TEST_JSON, + "lookupApplication returns the user-specific path when user-specific and system-wide entries both exist" + ); + deepEqual( + result.manifest, + templateManifest, + "lookupApplication returns user-specific manifest contents with user-specific and system-wide entries both exist" + ); +}); + +// Test shutdown handling in NativeApp +add_task(async function test_native_app_shutdown() { + const SCRIPT = String.raw` +import signal +import struct +import sys + +signal.signal(signal.SIGTERM, signal.SIG_IGN) + +stdin = getattr(sys.stdin, 'buffer', sys.stdin) +stdout = getattr(sys.stdout, 'buffer', sys.stdout) + +while True: + rawlen = stdin.read(4) + if len(rawlen) == 0: + signal.pause() + msglen = struct.unpack('@I', rawlen)[0] + msg = stdin.read(msglen) + + stdout.write(struct.pack('@I', msglen)) + stdout.write(msg) +`; + + let scriptPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.py"); + let manifestPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.json"); + + const ID = "native@tests.mozilla.org"; + let manifest = { + name: "wontdie", + description: "test async shutdown of native apps", + type: "stdio", + allowed_extensions: [ID], + }; + + if (AppConstants.platform == "win") { + await OS.File.writeAtomic(scriptPath, SCRIPT); + + let batPath = OS.Path.join(userDir.path, TYPE_SLUG, "wontdie.bat"); + let batBody = `@ECHO OFF\n${PYTHON} -u "${scriptPath}" %*\n`; + await OS.File.writeAtomic(batPath, batBody); + await OS.File.setPermissions(batPath, { unixMode: 0o755 }); + + manifest.path = batPath; + await writeManifest(manifestPath, manifest); + + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGPATH}\\wontdie`, + "", + manifestPath + ); + } else { + await OS.File.writeAtomic(scriptPath, `#!${PYTHON} -u\n${SCRIPT}`); + await OS.File.setPermissions(scriptPath, { unixMode: 0o755 }); + manifest.path = scriptPath; + await writeManifest(manifestPath, manifest); + } + + let mockContext = new MockContext(ID); + let app = new NativeApp(mockContext, "wontdie"); + + // send a message and wait for the reply to make sure the app is running + let MSG = "test"; + let recvPromise = new Promise(resolve => { + let listener = (what, msg) => { + equal(msg, MSG, "Received test message"); + app.off("message", listener); + resolve(); + }; + app.on("message", listener); + }); + + let buffer = NativeApp.encodeMessage(mockContext, MSG); + app.send(new StructuredCloneHolder(buffer)); + await recvPromise; + + app._cleanup(); + + info("waiting for async shutdown"); + Services.prefs.setBoolPref("toolkit.asyncshutdown.testing", true); + AsyncShutdown.profileBeforeChange._trigger(); + Services.prefs.clearUserPref("toolkit.asyncshutdown.testing"); + + let procs = await SubprocessImpl.Process.getWorker().call("getProcesses", []); + equal(procs.size, 0, "native process exited"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js new file mode 100644 index 0000000000..0763c60abe --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_incognito.js @@ -0,0 +1,103 @@ +"use strict"; + +/* eslint no-unused-vars: ["error", {"args": "none", "varsIgnorePattern": "^(FindProxyForURL)$"}] */ + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_incognito_proxy_onRequest_access() { + // No specific support exists in the proxy api for this test, + // rather it depends on functionality existing in ChannelWrapper + // that prevents notification of private channels if the + // extension does not have permission. + Services.prefs.setBoolPref("extensions.allowPrivateBrowsingByDefault", false); + + // This extension will fail if it gets a private request. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + async background() { + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set" + ); + browser.test.notifyPass("proxy.onRequest"); + }, + { urls: ["<all_urls>"], types: ["main_frame"] } + ); + + // Actual call arguments do not matter here. + await browser.test.assertRejects( + browser.proxy.settings.set({ + value: { + proxyType: "none", + }, + }), + /proxy.settings requires private browsing permission/, + "proxy.settings requires private browsing permission." + ); + + browser.test.sendMessage("ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("ready"); + + let pextension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertTrue( + details.incognito, + "incognito flag is set with filter" + ); + browser.test.sendMessage("proxy.onRequest.private"); + }, + { urls: ["<all_urls>"], types: ["main_frame"], incognito: true } + ); + + browser.proxy.onRequest.addListener( + async details => { + browser.test.assertFalse( + details.incognito, + "incognito flag is not set with filter" + ); + browser.test.notifyPass("proxy.onRequest.spanning"); + }, + { urls: ["<all_urls>"], types: ["main_frame"], incognito: false } + ); + }, + }); + await pextension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { privateBrowsing: true } + ); + await pextension.awaitMessage("proxy.onRequest.private"); + await contentPage.close(); + + contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy" + ); + await extension.awaitFinish("proxy.onRequest"); + await pextension.awaitFinish("proxy.onRequest.spanning"); + await contentPage.close(); + + await pextension.unload(); + await extension.unload(); + + Services.prefs.clearUserPref("extensions.allowPrivateBrowsingByDefault"); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js new file mode 100644 index 0000000000..c222642d52 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_info_results.js @@ -0,0 +1,469 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService" +); + +const TRANSPARENT_PROXY_RESOLVES_HOST = + Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + +let extension; +add_task(async function setup() { + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + let settings = { proxy: null }; + + browser.proxy.onError.addListener(error => { + browser.test.log(`error received ${error.message}`); + browser.test.sendMessage("proxy-error-received", error); + }); + browser.test.onMessage.addListener((message, data) => { + if (message === "set-proxy") { + settings.proxy = data.proxy; + browser.test.sendMessage("proxy-set", settings.proxy); + } + }); + browser.proxy.onRequest.addListener( + () => { + return settings.proxy; + }, + { urls: ["<all_urls>"] } + ); + }, + }; + extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); +}); + +async function setupProxyResult(proxy) { + extension.sendMessage("set-proxy", { proxy }); + let proxyInfoSent = await extension.awaitMessage("proxy-set"); + deepEqual( + proxyInfoSent, + proxy, + "got back proxy data from the proxy listener" + ); +} + +async function testProxyResolution(test) { + let { uri, proxy, expected } = test; + let errorMsg; + if (expected.error) { + errorMsg = extension.awaitMessage("proxy-error-received"); + } + let proxyInfo = await new Promise((resolve, reject) => { + let channel = NetUtil.newChannel({ + uri, + loadUsingSystemPrincipal: true, + }); + + gProxyService.asyncResolve(channel, 0, { + onProxyAvailable(req, uri, pi, status) { + resolve(pi && pi.QueryInterface(Ci.nsIProxyInfo)); + }, + }); + }); + + let expectedProxyInfo = expected.proxyInfo; + if (expected.error) { + equal(proxyInfo, null, "Expected proxyInfo to be null"); + equal((await errorMsg).message, expected.error, "error received"); + } else if (proxy == null) { + equal(proxyInfo, expectedProxyInfo, "proxy is direct"); + } else { + for ( + let proxyUsed = proxyInfo; + proxyUsed; + proxyUsed = proxyUsed.failoverProxy + ) { + let { + type, + host, + port, + username, + password, + proxyDNS, + failoverTimeout, + } = expectedProxyInfo; + equal(proxyUsed.host, host, `Expected proxy host to be ${host}`); + equal(proxyUsed.port, port, `Expected proxy port to be ${port}`); + equal(proxyUsed.type, type, `Expected proxy type to be ${type}`); + // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo + equal( + proxyUsed.username || "", + username || "", + `Expected proxy username to be ${username}` + ); + equal( + proxyUsed.password || "", + password || "", + `Expected proxy password to be ${password}` + ); + equal( + proxyUsed.flags, + proxyDNS == undefined ? 0 : proxyDNS, + `Expected proxyDNS to be ${proxyDNS}` + ); + // Default timeout is 10 + equal( + proxyUsed.failoverTimeout, + failoverTimeout || 10, + `Expected failoverTimeout to be ${failoverTimeout}` + ); + expectedProxyInfo = expectedProxyInfo.failoverProxy; + } + } +} + +add_task(async function test_proxyInfo_results() { + let tests = [ + { + proxy: 5, + expected: { + error: "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + { + proxy: "INVALID", + expected: { + error: "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + { + proxy: { + type: "socks", + }, + expected: { + error: 'ProxyInfoData: Invalid proxy server host: "undefined"', + }, + }, + { + proxy: [ + { + type: "pptp", + host: "foo.bar", + port: 1080, + username: "mungosantamaria", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { + type: "http", + host: "192.168.1.1", + port: 1128, + username: "mungosantamaria", + password: "word321", + }, + ], + expected: { + error: 'ProxyInfoData: Invalid proxy server type: "pptp"', + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 65536, + username: "mungosantamaria", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { + type: "http", + host: "192.168.1.1", + port: 3128, + username: "mungosantamaria", + password: "word321", + }, + ], + expected: { + error: + "ProxyInfoData: Proxy server port 65536 outside range 1 to 65535", + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 3128, + proxyAuthorizationHeader: "test", + }, + ], + expected: { + error: 'ProxyInfoData: ProxyAuthorizationHeader requires type "https"', + }, + }, + { + proxy: [ + { + type: "http", + host: "foo.bar", + port: 3128, + connectionIsolationKey: 1234, + }, + ], + expected: { + error: 'ProxyInfoData: Invalid proxy connection isolation key: "1234"', + }, + }, + { + proxy: [{ type: "direct" }], + expected: { + proxyInfo: null, + }, + }, + { + proxy: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: null, + }, + }, + }, + { + uri: "ftp://mozilla.org", + proxy: { + host: "1.2.3.4", + port: "8180", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8180", + type: "http", + failoverProxy: null, + }, + }, + }, + { + proxy: { + host: "2.3.4.5", + port: "8181", + type: "http", + failoverProxy: null, + }, + expected: { + proxyInfo: { + host: "2.3.4.5", + port: "8181", + type: "http", + failoverProxy: null, + }, + }, + }, + { + proxy: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: { + host: "4.4.4.4", + port: "9000", + type: "socks", + failoverProxy: { + type: "direct", + host: null, + port: -1, + }, + }, + }, + expected: { + proxyInfo: { + host: "1.2.3.4", + port: "8080", + type: "http", + failoverProxy: { + host: "4.4.4.4", + port: "9000", + type: "socks", + failoverProxy: { + type: "direct", + host: null, + port: -1, + }, + }, + }, + }, + }, + { + proxy: [{ type: "http", host: "foo.bar", port: 3128 }], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "http", + }, + }, + }, + { + proxy: { + host: "foo.bar", + port: "1080", + type: "socks", + }, + expected: { + proxyInfo: { + host: "foo.bar", + port: "1080", + type: "socks", + }, + }, + }, + { + proxy: { + host: "foo.bar", + port: "1080", + type: "socks4", + }, + expected: { + proxyInfo: { + host: "foo.bar", + port: "1080", + type: "socks4", + }, + }, + }, + { + proxy: [{ type: "https", host: "foo.bar", port: 3128 }], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "https", + }, + }, + }, + { + proxy: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "mungo", + password: "santamaria123", + proxyDNS: true, + failoverTimeout: 5, + }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + username: "mungo", + password: "santamaria123", + failoverTimeout: 5, + failoverProxy: null, + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + }, + }, + }, + { + proxy: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "johnsmith", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { type: "http", host: "192.168.1.1", port: 3128 }, + { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 }, + { + type: "socks", + host: "192.168.1.3", + port: 1999, + proxyDNS: true, + username: "mungosantamaria", + password: "foobar", + }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + proxyDNS: true, + username: "johnsmith", + password: "pass123", + failoverTimeout: 3, + failoverProxy: { + host: "192.168.1.1", + port: 3128, + type: "http", + failoverProxy: { + host: "192.168.1.2", + port: 1121, + type: "https", + failoverTimeout: 1, + failoverProxy: { + host: "192.168.1.3", + port: 1999, + type: "socks", + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + username: "mungosantamaria", + password: "foobar", + failoverProxy: { + type: "direct", + }, + }, + }, + }, + }, + }, + }, + { + proxy: [ + { + type: "https", + host: "foo.bar", + port: 3128, + proxyAuthorizationHeader: "test", + connectionIsolationKey: "key", + }, + ], + expected: { + proxyInfo: { + host: "foo.bar", + port: "3128", + type: "https", + proxyAuthorizationHeader: "test", + connectionIsolationKey: "key", + }, + }, + }, + ]; + for (let test of tests) { + await setupProxyResult(test.proxy); + if (!test.uri) { + test.uri = "http://www.mozilla.org/"; + } + await testProxyResolution(test); + } +}); + +add_task(async function shutdown() { + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js new file mode 100644 index 0000000000..5dc099baf6 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_listener.js @@ -0,0 +1,318 @@ +"use strict"; + +XPCOMUtils.defineLazyServiceGetter( + this, + "gProxyService", + "@mozilla.org/network/protocol-proxy-service;1", + "nsIProtocolProxyService" +); + +const TRANSPARENT_PROXY_RESOLVES_HOST = + Ci.nsIProxyInfo.TRANSPARENT_PROXY_RESOLVES_HOST; + +function getProxyInfo(url = "http://www.mozilla.org/") { + return new Promise((resolve, reject) => { + let channel = NetUtil.newChannel({ + uri: url, + loadUsingSystemPrincipal: true, + }); + + gProxyService.asyncResolve(channel, 0, { + onProxyAvailable(req, uri, pi, status) { + resolve(pi); + }, + }); + }); +} + +const testData = [ + { + // An ExtensionError is thrown for this, but we are unable to catch it as we + // do with the PAC script api. In this case, we expect null for proxyInfo. + proxyInfo: "not_defined", + expected: { + proxyInfo: null, + }, + }, + { + proxyInfo: 1, + expected: { + error: { + message: + "ProxyInfoData: proxyData must be an object or array of objects", + }, + }, + }, + { + proxyInfo: [ + { + type: "socks", + host: "foo.bar", + port: 1080, + username: "johnsmith", + password: "pass123", + proxyDNS: true, + failoverTimeout: 3, + }, + { type: "http", host: "192.168.1.1", port: 3128 }, + { type: "https", host: "192.168.1.2", port: 1121, failoverTimeout: 1 }, + { + type: "socks", + host: "192.168.1.3", + port: 1999, + proxyDNS: true, + username: "mungosantamaria", + password: "foobar", + }, + { type: "direct" }, + ], + expected: { + proxyInfo: { + type: "socks", + host: "foo.bar", + port: 1080, + proxyDNS: true, + username: "johnsmith", + password: "pass123", + failoverTimeout: 3, + failoverProxy: { + host: "192.168.1.1", + port: 3128, + type: "http", + failoverProxy: { + host: "192.168.1.2", + port: 1121, + type: "https", + failoverTimeout: 1, + failoverProxy: { + host: "192.168.1.3", + port: 1999, + type: "socks", + proxyDNS: TRANSPARENT_PROXY_RESOLVES_HOST, + username: "mungosantamaria", + password: "foobar", + failoverProxy: { + type: "direct", + }, + }, + }, + }, + }, + }, + }, +]; + +add_task(async function test_proxy_listener() { + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + // Some tests generate multiple errors, we'll just rely on the first. + let seenError = false; + let proxyInfo; + browser.proxy.onError.addListener(error => { + if (!seenError) { + browser.test.sendMessage("proxy-error-received", error); + seenError = true; + } + }); + + browser.proxy.onRequest.addListener( + details => { + browser.test.log(`onRequest ${JSON.stringify(details)}`); + if (proxyInfo == "not_defined") { + return not_defined; // eslint-disable-line no-undef + } + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + + browser.test.onMessage.addListener((message, data) => { + if (message === "set-proxy") { + seenError = false; + proxyInfo = data.proxyInfo; + } + }); + + browser.test.sendMessage("ready"); + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let test of testData) { + extension.sendMessage("set-proxy", test); + let testError = test.expected.error; + let errorWait = testError && extension.awaitMessage("proxy-error-received"); + + let proxyInfo = await getProxyInfo(); + let expectedProxyInfo = test.expected.proxyInfo; + + if (testError) { + info("waiting for error data"); + let error = await errorWait; + equal(error.message, testError.message, "Correct error message received"); + equal(proxyInfo, null, "no proxyInfo received"); + } else if (expectedProxyInfo === null) { + equal(proxyInfo, null, "no proxyInfo received"); + } else { + for ( + let proxyUsed = proxyInfo; + proxyUsed; + proxyUsed = proxyUsed.failoverProxy + ) { + let { + type, + host, + port, + username, + password, + proxyDNS, + failoverTimeout, + } = expectedProxyInfo; + equal(proxyUsed.host, host, `Expected proxy host to be ${host}`); + equal(proxyUsed.port, port || -1, `Expected proxy port to be ${port}`); + equal(proxyUsed.type, type, `Expected proxy type to be ${type}`); + // May be null or undefined depending on use of newProxyInfoWithAuth or newProxyInfo + equal( + proxyUsed.username || "", + username || "", + `Expected proxy username to be ${username}` + ); + equal( + proxyUsed.password || "", + password || "", + `Expected proxy password to be ${password}` + ); + equal( + proxyUsed.flags, + proxyDNS == undefined ? 0 : proxyDNS, + `Expected proxyDNS to be ${proxyDNS}` + ); + // Default timeout is 10 + equal( + proxyUsed.failoverTimeout, + failoverTimeout || 10, + `Expected failoverTimeout to be ${failoverTimeout}` + ); + expectedProxyInfo = expectedProxyInfo.failoverProxy; + } + ok(!expectedProxyInfo, "no left over failoverProxy"); + } + } + + await extension.unload(); +}); + +async function getExtension(expectedProxyInfo) { + function background(proxyInfo) { + browser.test.log( + `testing proxy.onRequest with proxyInfo = ${JSON.stringify(proxyInfo)}` + ); + browser.proxy.onRequest.addListener( + details => { + return proxyInfo; + }, + { urls: ["<all_urls>"] } + ); + } + let extensionData = { + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background: `(${background})(${JSON.stringify(expectedProxyInfo)})`, + }; + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + return extension; +} + +add_task(async function test_passthrough() { + let ext1 = await getExtension(null); + let ext2 = await getExtension({ host: "1.2.3.4", port: 8888, type: "https" }); + + // Also use a restricted url to test the ability to proxy those. + let proxyInfo = await getProxyInfo("https://addons.mozilla.org/"); + + equal(proxyInfo.host, "1.2.3.4", `second extension won`); + equal(proxyInfo.port, "8888", `second extension won`); + equal(proxyInfo.type, "https", `second extension won`); + + await ext2.unload(); + + proxyInfo = await getProxyInfo(); + equal(proxyInfo, null, `expected no proxy`); + await ext1.unload(); +}); + +add_task(async function test_ftp() { + Services.prefs.setBoolPref("network.ftp.enabled", true); + let extension = await getExtension({ + host: "1.2.3.4", + port: 8888, + type: "http", + }); + + let proxyInfo = await getProxyInfo("ftp://somewhere.mozilla.org/"); + + equal(proxyInfo.host, "1.2.3.4", `proxy host correct`); + equal(proxyInfo.port, "8888", `proxy port correct`); + equal(proxyInfo.type, "http", `proxy type correct`); + + await extension.unload(); + Services.prefs.clearUserPref("network.ftp.enabled"); +}); + +add_task(async function test_ftp_disabled() { + Services.prefs.setBoolPref("network.ftp.enabled", false); + let extension = await getExtension({ + host: "1.2.3.4", + port: 8888, + type: "http", + }); + + let proxyInfo = await getProxyInfo("ftp://somewhere.mozilla.org/"); + + equal( + proxyInfo, + null, + `proxy of ftp request is not available when ftp is disabled` + ); + + await extension.unload(); + Services.prefs.clearUserPref("network.ftp.enabled"); +}); + +add_task(async function test_ws() { + let proxyRequestCount = 0; + let proxy = createHttpServer(); + proxy.registerPathHandler("CONNECT", (request, response) => { + response.setStatusLine(request.httpVersion, 404, "Proxy not found"); + ++proxyRequestCount; + }); + + let extension = await getExtension({ + host: proxy.identity.primaryHost, + port: proxy.identity.primaryPort, + type: "http", + }); + + // We need a page to use the WebSocket constructor, so let's use an extension. + let dummy = ExtensionTestUtils.loadExtension({ + background() { + // The connection will not be upgraded to WebSocket, so it will close. + let ws = new WebSocket("wss://example.net/"); + ws.onclose = () => browser.test.sendMessage("websocket_closed"); + }, + }); + await dummy.startup(); + await dummy.awaitMessage("websocket_closed"); + await dummy.unload(); + + equal(proxyRequestCount, 1, "Expected one proxy request"); + await extension.unload(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js new file mode 100644 index 0000000000..5dea560e02 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_proxy_userContextId.js @@ -0,0 +1,43 @@ +"use strict"; + +const server = createHttpServer({ hosts: ["example.com"] }); + +server.registerPathHandler("/dummy", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + response.write("<!DOCTYPE html><html></html>"); +}); + +add_task(async function test_userContextId_proxy_onRequest() { + // This extension will succeed if it gets a request + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["proxy", "<all_urls>"], + }, + background() { + browser.proxy.onRequest.addListener( + async details => { + if (details.url != "http://example.com/dummy") { + return; + } + browser.test.assertEq( + details.cookieStoreId, + "firefox-container-2", + "cookieStoreId is set" + ); + browser.test.notifyPass("proxy.onRequest"); + }, + { urls: ["<all_urls>"] } + ); + }, + }); + await extension.startup(); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/dummy", + { userContextId: 2 } + ); + await extension.awaitFinish("proxy.onRequest"); + await extension.unload(); + await contentPage.close(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js new file mode 100644 index 0000000000..7c083c7805 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_ancestors.js @@ -0,0 +1,79 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.import( + "resource://gre/modules/WebRequest.jsm" +); +var { PromiseUtils } = ChromeUtils.import( + "resource://gre/modules/PromiseUtils.jsm" +); +var { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +add_task(async function setup() { + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function test_ancestors_exist() { + let deferred = PromiseUtils.defer(); + function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + ok( + typeof details.frameAncestors === "object", + `ancestors exists [${typeof details.frameAncestors}]` + ); + deferred.resolve(); + } + + WebRequest.onBeforeRequest.addListener( + onBeforeRequest, + { urls: new MatchPatternSet(["http://example.com/*"]) }, + ["blocking"] + ); + + let contentPage = await ExtensionTestUtils.loadContentPage( + "http://example.com/data/file_sample.html" + ); + await deferred.promise; + await contentPage.close(); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); +}); + +add_task(async function test_ancestors_null() { + let deferred = PromiseUtils.defer(); + function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + ok(details.frameAncestors === undefined, "ancestors do not exist"); + deferred.resolve(); + } + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, null, ["blocking"]); + + function fetch(url) { + return new Promise((resolve, reject) => { + let xhr = new XMLHttpRequest(); + xhr.mozBackgroundRequest = true; + xhr.open("GET", url); + xhr.onload = () => { + resolve(xhr.responseText); + }; + xhr.onerror = () => { + reject(xhr.status); + }; + // use a different contextId to avoid auth cache. + xhr.setOriginAttributes({ userContextId: 1 }); + xhr.send(); + }); + } + + await fetch("http://example.com/data/file_sample.html"); + await deferred.promise; + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js new file mode 100644 index 0000000000..d13b2be40d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_cookies.js @@ -0,0 +1,102 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.import( + "resource://gre/modules/WebRequest.jsm" +); + +var { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerPathHandler("/", (request, response) => { + response.setStatusLine(request.httpVersion, 200, "OK"); + if (request.hasHeader("Cookie")) { + let value = request.getHeader("Cookie"); + if (value == "blinky=1") { + response.setHeader("Set-Cookie", "dinky=1", false); + } + response.write("cookie-present"); + } else { + response.setHeader("Set-Cookie", "foopy=1", false); + response.write("cookie-not-present"); + } +}); + +const URL = "http://example.com/"; + +var countBefore = 0; +var countAfter = 0; + +function onBeforeSendHeaders(details) { + if (details.url != URL) { + return undefined; + } + + countBefore++; + + info(`onBeforeSendHeaders ${details.url}`); + let found = false; + let headers = []; + for (let { name, value } of details.requestHeaders) { + info(`Saw header ${name} '${value}'`); + if (name == "Cookie") { + equal(value, "foopy=1", "Cookie is correct"); + headers.push({ name, value: "blinky=1" }); + found = true; + } else { + headers.push({ name, value }); + } + } + ok(found, "Saw cookie header"); + equal(countBefore, 1, "onBeforeSendHeaders hit once"); + + return { requestHeaders: headers }; +} + +function onResponseStarted(details) { + if (details.url != URL) { + return; + } + + countAfter++; + + info(`onResponseStarted ${details.url}`); + let found = false; + for (let { name, value } of details.responseHeaders) { + info(`Saw header ${name} '${value}'`); + if (name == "set-cookie") { + equal(value, "dinky=1", "Cookie is correct"); + found = true; + } + } + ok(found, "Saw cookie header"); + equal(countAfter, 1, "onResponseStarted hit once"); +} + +add_task(async function setup() { + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function filter_urls() { + // First load the URL so that we set cookie foopy=1. + let contentPage = await ExtensionTestUtils.loadContentPage(URL); + await contentPage.close(); + + // Now load with WebRequest set up. + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, null, [ + "blocking", + "requestHeaders", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, null, [ + "responseHeaders", + ]); + + contentPage = await ExtensionTestUtils.loadContentPage(URL); + await contentPage.close(); + + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); diff --git a/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js new file mode 100644 index 0000000000..156ba6267d --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_webRequest_filtering.js @@ -0,0 +1,182 @@ +"use strict"; + +var { WebRequest } = ChromeUtils.import( + "resource://gre/modules/WebRequest.jsm" +); + +var { ExtensionParent } = ChromeUtils.import( + "resource://gre/modules/ExtensionParent.jsm" +); + +const server = createHttpServer({ hosts: ["example.com"] }); +server.registerDirectory("/data/", do_get_file("data")); + +const BASE = "http://example.com/data/"; +const URL = BASE + "/file_WebRequest_page2.html"; + +var requested = []; + +function onBeforeRequest(details) { + info(`onBeforeRequest ${details.url}`); + if (details.url.startsWith(BASE)) { + requested.push(details.url); + } +} + +var sendHeaders = []; + +function onBeforeSendHeaders(details) { + info(`onBeforeSendHeaders ${details.url}`); + if (details.url.startsWith(BASE)) { + sendHeaders.push(details.url); + } +} + +var completed = []; + +function onResponseStarted(details) { + if (details.url.startsWith(BASE)) { + completed.push(details.url); + } +} + +const expected_urls = [ + BASE + "/file_style_good.css", + BASE + "/file_style_bad.css", + BASE + "/file_style_redirect.css", +]; + +function resetExpectations() { + requested.length = 0; + sendHeaders.length = 0; + completed.length = 0; +} + +function removeDupes(list) { + let j = 0; + for (let i = 1; i < list.length; i++) { + if (list[i] != list[j]) { + j++; + if (i != j) { + list[j] = list[i]; + } + } + } + list.length = j + 1; +} + +function compareLists(list1, list2, kind) { + list1.sort(); + removeDupes(list1); + list2.sort(); + removeDupes(list2); + equal(String(list1), String(list2), `${kind} URLs correct`); +} + +async function openAndCloseContentPage(url) { + let contentPage = await ExtensionTestUtils.loadContentPage(URL); + // Clear the sheet cache so that it doesn't interact with following tests: A + // stylesheet with the same URI loaded from the same origin doesn't otherwise + // guarantee that onBeforeRequest and so on happen, because it may not need + // to go through necko at all. + await contentPage.spawn(null, () => + content.windowUtils.clearSharedStyleSheetCache() + ); + await contentPage.close(); +} + +add_task(async function setup() { + // Disable rcwn to make cache behavior deterministic. + Services.prefs.setBoolPref("network.http.rcwn.enabled", false); + + // When WebRequest.jsm is used directly instead of through ext-webRequest.js, + // ExtensionParent.apiManager is not automatically initialized. Do it here. + await ExtensionParent.apiManager.lazyInit(); +}); + +add_task(async function filter_urls() { + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]) }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, expected_urls, "requested"); + compareLists(sendHeaders, expected_urls, "sendHeaders"); + compareLists(completed, expected_urls, "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_types() { + resetExpectations(); + let filter = { types: ["stylesheet"] }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, expected_urls, "requested"); + compareLists(sendHeaders, expected_urls, "sendHeaders"); + compareLists(completed, expected_urls, "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_windowId() { + resetExpectations(); + // Check that adding windowId will exclude non-matching requests. + // test_ext_webrequest_filter.html provides coverage for matching requests. + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), windowId: 0 }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, [], "requested"); + compareLists(sendHeaders, [], "sendHeaders"); + compareLists(completed, [], "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); + +add_task(async function filter_tabId() { + resetExpectations(); + // Check that adding tabId will exclude non-matching requests. + // test_ext_webrequest_filter.html provides coverage for matching requests. + let filter = { urls: new MatchPatternSet(["*://*/*_style_*"]), tabId: 0 }; + + WebRequest.onBeforeRequest.addListener(onBeforeRequest, filter, ["blocking"]); + WebRequest.onBeforeSendHeaders.addListener(onBeforeSendHeaders, filter, [ + "blocking", + ]); + WebRequest.onResponseStarted.addListener(onResponseStarted, filter); + + await openAndCloseContentPage(URL); + + compareLists(requested, [], "requested"); + compareLists(sendHeaders, [], "sendHeaders"); + compareLists(completed, [], "completed"); + + WebRequest.onBeforeRequest.removeListener(onBeforeRequest); + WebRequest.onBeforeSendHeaders.removeListener(onBeforeSendHeaders); + WebRequest.onResponseStarted.removeListener(onResponseStarted); +}); diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini new file mode 100644 index 0000000000..332921e685 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common-e10s.ini @@ -0,0 +1,13 @@ +# Similar to xpcshell-common.ini, except tests here only run +# when e10s is enabled (with or without out-of-process extensions). + +[test_ext_webRequest_filterResponseData.js] +# tsan failure is for test_filter_301 timing out, bug 1674773 +skip-if = tsan || os == "android" && debug +[test_ext_webRequest_redirect_StreamFilter.js] +[test_ext_webRequest_responseBody.js] +skip-if = os == "android" && debug +[test_ext_webRequest_startup_StreamFilter.js] +skip-if = os == "android" && debug +[test_ext_webRequest_viewsource_StreamFilter.js] +skip-if = tsan # Bug 1683730
\ No newline at end of file diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini new file mode 100644 index 0000000000..32d76194bb --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-common.ini @@ -0,0 +1,260 @@ +[DEFAULT] +# Some tests of downloads.download() expect a file picker, which is only shown +# by default when the browser.download.useDownloadDir pref is set to true. This +# is the case on desktop Firefox, but not on Thunderbird. +# Force pref value to true to get download tests to pass on Thunderbird. +prefs = browser.download.useDownloadDir=true + +[test_change_remote_mode.js] +[test_ext_MessageManagerProxy.js] +skip-if = os == 'android' # Bug 1545439 +[test_ext_activityLog.js] +[test_ext_alarms.js] +[test_ext_alarms_does_not_fire.js] +[test_ext_alarms_periodic.js] +[test_ext_alarms_replaces.js] +[test_ext_api_permissions.js] +[test_ext_background_api_injection.js] +[test_ext_background_early_shutdown.js] +[test_ext_background_generated_load_events.js] +[test_ext_background_generated_reload.js] +[test_ext_background_global_history.js] +skip-if = os == "android" # Android does not use Places for history. +[test_ext_background_private_browsing.js] +[test_ext_background_runtime_connect_params.js] +[test_ext_background_sub_windows.js] +[test_ext_background_teardown.js] +[test_ext_background_telemetry.js] +[test_ext_background_window_properties.js] +skip-if = os == "android" +[test_ext_browserSettings.js] +[test_ext_browserSettings_homepage.js] +skip-if = appname == "thunderbird" || os == "android" +[test_ext_browsingData.js] +[test_ext_browsingData_cookies_cache.js] +[test_ext_browsingData_cookies_cookieStoreId.js] +[test_ext_captivePortal.js] +# As with test_captive_portal_service.js, we use the same limits here. +skip-if = appname == "thunderbird" || os == "android" || (os == "mac" && debug) # CP service is disabled on Android, macosx1014/debug due to 1564534 +run-sequentially = node server exceptions dont replay well +[test_ext_captivePortal_url.js] +# As with test_captive_portal_service.js, we use the same limits here. +skip-if = appname == "thunderbird" || os == "android" || (os == "mac" && debug) # CP service is disabled on Android, macosx1014/debug due to 1564534 +run-sequentially = node server exceptions dont replay well +[test_ext_cookieBehaviors.js] +skip-if = appname == "thunderbird" || tsan # Bug 1683730 +[test_ext_cookies_firstParty.js] +skip-if = appname == "thunderbird" || os == "android" || tsan # Android: Bug 1680132. tsan: Bug 1683730 +[test_ext_cookies_samesite.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_content_security_policy.js] +skip-if = (os == "win" && debug) #Bug 1485567 +[test_ext_contentscript_api_injection.js] +[test_ext_contentscript_async_loading.js] +skip-if = os == 'android' && debug # The generated script takes too long to load on Android debug +[test_ext_contentscript_context.js] +skip-if = tsan # Bug 1683730 +[test_ext_contentscript_context_isolation.js] +skip-if = tsan # Bug 1683730 +[test_ext_contentscript_create_iframe.js] +[test_ext_contentscript_csp.js] +[test_ext_contentscript_css.js] +[test_ext_contentscript_exporthelpers.js] +[test_ext_contentscript_in_background.js] +[test_ext_contentscript_restrictSchemes.js] +[test_ext_contentscript_teardown.js] +skip-if = tsan # Bug 1683730 +[test_ext_contentscript_unregister_during_loadContentScript.js] +[test_ext_contentscript_xml_prettyprint.js] +[test_ext_contextual_identities.js] +skip-if = appname == "thunderbird" || os == "android" # Containers are not exposed to android. +[test_ext_debugging_utils.js] +[test_ext_dns.js] +skip-if = socketprocess_networking || os == "android" # Android: Bug 1680132 +[test_ext_downloads.js] +[test_ext_downloads_cookies.js] +skip-if = os == "android" # downloads API needs to be implemented in GeckoView - bug 1538348 +[test_ext_downloads_download.js] +skip-if = appname == "thunderbird" || os == "android" || tsan # tsan: bug 1612707 +[test_ext_downloads_misc.js] +skip-if = os == "android" || (os=='linux' && bits==32) || tsan # linux32: bug 1324870, tsan: bug 1612707 +[test_ext_downloads_private.js] +skip-if = os == "android" +[test_ext_downloads_search.js] +skip-if = os == "android" || tsan # tsan: bug 1612707 +[test_ext_downloads_urlencoded.js] +skip-if = os == "android" +[test_ext_error_location.js] +[test_ext_eventpage_warning.js] +[test_ext_experiments.js] +[test_ext_extension.js] +[test_ext_extensionPreferencesManager.js] +[test_ext_extensionSettingsStore.js] +[test_ext_extension_content_telemetry.js] +skip-if = os == "android" # checking for telemetry needs to be updated: 1384923 +[test_ext_extension_startup_failure.js] +[test_ext_extension_startup_telemetry.js] +[test_ext_file_access.js] +[test_ext_geckoProfiler_control.js] +skip-if = os == "android" || tsan # Not shipped on Android. tsan: bug 1612707 +[test_ext_geturl.js] +[test_ext_idle.js] +[test_ext_incognito.js] +skip-if = appname == "thunderbird" +[test_ext_l10n.js] +[test_ext_localStorage.js] +[test_ext_management.js] +skip-if = (os == "win" && !debug) #Bug 1419183 disable on Windows +[test_ext_management_uninstall_self.js] +[test_ext_messaging_startup.js] +skip-if = appname == "thunderbird" || (os == "android" && debug) +[test_ext_networkStatus.js] +[test_ext_notifications_incognito.js] +skip-if = appname == "thunderbird" +[test_ext_notifications_unsupported.js] +[test_ext_onmessage_removelistener.js] +skip-if = true # This test no longer tests what it is meant to test. +[test_ext_permission_xhr.js] +[test_ext_persistent_events.js] +[test_ext_privacy.js] +skip-if = appname == "thunderbird" || (os == "android" && debug) || (os == "linux" && !debug) #Bug 1625455 +[test_ext_privacy_disable.js] +skip-if = appname == "thunderbird" +[test_ext_privacy_update.js] +[test_ext_proxy_authorization_via_proxyinfo.js] +skip-if = true # Bug 1622433 needs h2 proxy implementation +[test_ext_proxy_config.js] +skip-if = appname == "thunderbird" || os == "android" # Android: Bug 1680132 +[test_ext_proxy_onauthrequired.js] +[test_ext_proxy_settings.js] +skip-if = appname == "thunderbird" || os == "android" # proxy settings are not supported on android +[test_ext_proxy_socks.js] +skip-if = socketprocess_networking +run-sequentially = TCPServerSocket fails otherwise +[test_ext_proxy_speculative.js] +skip-if = ccov && os == 'linux' # bug 1607581 +[test_ext_proxy_startup.js] +skip-if = ccov && os == 'linux' # bug 1607581 +[test_ext_redirects.js] +skip-if = os == "android" && debug +[test_ext_runtime_connect_no_receiver.js] +[test_ext_runtime_getBrowserInfo.js] +[test_ext_runtime_getPlatformInfo.js] +[test_ext_runtime_id.js] +skip-if = ccov && os == 'linux' # bug 1607581 +[test_ext_runtime_messaging_self.js] +[test_ext_runtime_onInstalled_and_onStartup.js] +[test_ext_runtime_ports.js] +[test_ext_runtime_ports_gc.js] +[test_ext_runtime_sendMessage.js] +[test_ext_runtime_sendMessage_errors.js] +[test_ext_runtime_sendMessage_multiple.js] +[test_ext_runtime_sendMessage_no_receiver.js] +[test_ext_same_site_cookies.js] +[test_ext_same_site_redirects.js] +[test_ext_sandbox_var.js] +[test_ext_schema.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_shared_workers.js] +[test_ext_shutdown_cleanup.js] +[test_ext_simple.js] +[test_ext_startupData.js] +[test_ext_startup_cache.js] +skip-if = os == "android" +[test_ext_startup_perf.js] +[test_ext_startup_request_handler.js] +[test_ext_storage_local.js] +skip-if = os == "android" && debug +[test_ext_storage_idb_data_migration.js] +skip-if = appname == "thunderbird" || (os == "android" && debug) +[test_ext_storage_content_local.js] +skip-if = os == "android" && debug +[test_ext_storage_content_sync.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_storage_content_sync_kinto.js] +skip-if = os == "android" && debug +[test_ext_storage_quota_exceeded_errors.js] +skip-if = os == "android" # Bug 1564871 +[test_ext_storage_managed.js] +skip-if = os == "android" +[test_ext_storage_managed_policy.js] +skip-if = appname == "thunderbird" || os == "android" +[test_ext_storage_sanitizer.js] +skip-if = appname == "thunderbird" || os == "android" # Android: Bug 1680132 +[test_ext_storage_sync.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_storage_sync_kinto.js] +skip-if = appname == "thunderbird" || os == "android" +[test_ext_storage_sync_kinto_crypto.js] +skip-if = appname == "thunderbird" || os == "android" +[test_ext_storage_tab.js] +[test_ext_storage_telemetry.js] +skip-if = os == "android" # checking for telemetry needs to be updated: 1384923 +[test_ext_tab_teardown.js] +skip-if = os == 'android' # Bug 1258975 on android. +[test_ext_telemetry.js] +[test_ext_trustworthy_origin.js] +[test_ext_unlimitedStorage.js] +[test_ext_unload_frame.js] +skip-if = true # Too frequent intermittent failures +[test_ext_userScripts.js] +[test_ext_userScripts_exports.js] +[test_ext_userScripts_telemetry.js] +[test_ext_webRequest_auth.js] +skip-if = os == "android" && debug +[test_ext_webRequest_cached.js] +skip-if = os == "android" # Bug 1573511 +[test_ext_webRequest_cancelWithReason.js] +[test_ext_webRequest_download.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_webRequest_filterTypes.js] +[test_ext_webRequest_from_extension_page.js] +[test_ext_webRequest_incognito.js] +skip-if = os == "android" && debug +[test_ext_webRequest_filter_urls.js] +[test_ext_webRequest_host.js] +skip-if = os == "android" && debug +[test_ext_webRequest_mergecsp.js] +skip-if = tsan # Bug 1683730 +[test_ext_webRequest_permission.js] +skip-if = os == "android" && debug +[test_ext_webRequest_redirect_mozextension.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_webRequest_requestSize.js] +skip-if = socketprocess_networking +[test_ext_webRequest_set_cookie.js] +skip-if = appname == "thunderbird" +[test_ext_webRequest_startup.js] +skip-if = os == "android" # bug 1683159 +[test_ext_webRequest_style_cache.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_webRequest_suspend.js] +[test_ext_webRequest_userContextId.js] +[test_ext_webRequest_viewsource.js] +[test_ext_webRequest_webSocket.js] +skip-if = appname == "thunderbird" +[test_ext_xhr_capabilities.js] +[test_native_manifests.js] +subprocess = true +skip-if = os == "android" +[test_ext_permissions.js] +skip-if = appname == "thunderbird" || os == "android" # Bug 1350559 +[test_ext_permissions_api.js] +skip-if = appname == "thunderbird" || os == "android" # Bug 1350559 +[test_ext_permissions_migrate.js] +skip-if = appname == "thunderbird" || os == "android" # Bug 1350559 +[test_ext_permissions_uninstall.js] +skip-if = appname == "thunderbird" || os == "android" # Bug 1350559 +[test_proxy_listener.js] +skip-if = appname == "thunderbird" +[test_proxy_incognito.js] +skip-if = os == "android" # incognito not supported on android +[test_proxy_info_results.js] +[test_proxy_userContextId.js] +[test_webRequest_ancestors.js] +[test_webRequest_cookies.js] +[test_webRequest_filtering.js] +[test_ext_brokenlinks.js] +skip-if = os == "android" # Android: Bug 1680132 +[test_ext_performance_counters.js] +skip-if = appname == "thunderbird" || os == "android" diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini new file mode 100644 index 0000000000..0950f7a9d3 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-content.ini @@ -0,0 +1,22 @@ +[DEFAULT] +prefs = + javascript.options.experimental.private_fields=true + +[test_ext_i18n.js] +skip-if = os == "android" || (os == "win" && debug) || (os == "linux") +[test_ext_i18n_css.js] +[test_ext_contentscript.js] +[test_ext_contentscript_about_blank_start.js] +[test_ext_contentscript_canvas_tainting.js] +[test_ext_contentscript_scriptCreated.js] +[test_ext_contentscript_triggeringPrincipal.js] +skip-if = os == "android" || (os == "win" && debug) || tsan || socketprocess_networking # Windows: Bug 1438796, tsan: bug 1612707, Android: Bug 1680132 +[test_ext_contentscript_xrays.js] +[test_ext_contentScripts_register.js] +[test_ext_contexts_gc.js] +[test_ext_adoption_with_xrays.js] +[test_ext_adoption_with_private_field_xrays.js] +skip-if = !nightly_build +[test_ext_shadowdom.js] +skip-if = ccov && os == 'linux' # bug 1607581 +[test_ext_web_accessible_resources.js] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini new file mode 100644 index 0000000000..228492d00b --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-e10s.ini @@ -0,0 +1,28 @@ +[DEFAULT] +head = head.js head_e10s.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +dupe-manifest = +support-files = + data/** + xpcshell-content.ini +tags = webextensions webextensions-e10s + +# services.settings.server/default_bucket: +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the default remote settings bucket pref name to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=http://localhost:7777/remote-settings-dummy/v1 + services.settings.default_bucket=nonexistent-bucket-foo + +[include:xpcshell-common-e10s.ini] +[include:xpcshell-content.ini] + +# Tests that need to run with e10s only must NOT be placed here, +# but in xpcshell-common-e10s.ini. +# A test here will only run on one configuration, e10s + in-process extensions, +# while the primary target is e10s + out-of-process extensions. +# xpcshell-common-e10s.ini runs in both configurations. diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini new file mode 100644 index 0000000000..2df5e54b68 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-legacy-ep.ini @@ -0,0 +1,23 @@ +[DEFAULT] +head = head.js head_remote.js head_e10s.js head_legacy_ep.js +tail = +firefox-appdir = browser +skip-if = appname == "thunderbird" || os == "android" +dupe-manifest = + +# services.settings.server/default_bucket: +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the default remote settings bucket pref name to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=http://localhost:7777/remote-settings-dummy/v1 + services.settings.default_bucket=nonexistent-bucket-foo + +# Bug 1646182: Test the legacy ExtensionPermission backend until we fully +# migrate to rkv +[test_ext_permissions.js] +[test_ext_permissions_api.js] +[test_ext_permissions_migrate.js] +[test_ext_permissions_uninstall.js] +[test_ext_proxy_config.js] diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini new file mode 100644 index 0000000000..2ccd923230 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell-remote.ini @@ -0,0 +1,30 @@ +[DEFAULT] +head = head.js head_remote.js head_e10s.js head_telemetry.js head_sync.js head_storage.js +tail = +firefox-appdir = browser +skip-if = os == "android" +dupe-manifest = +support-files = + data/** + xpcshell-content.ini +tags = webextensions remote-webextensions + +# services.settings.server/default_bucket: +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the default remote settings bucket pref name to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=http://localhost:7777/remote-settings-dummy/v1 + services.settings.default_bucket=nonexistent-bucket-foo + +[include:xpcshell-common.ini] +[include:xpcshell-common-e10s.ini] +[include:xpcshell-content.ini] + +[test_ext_contentscript_perf_observers.js] # Inexplicably, PerformanceObserver used in the test doesn't fire in non-e10s mode. +skip-if = tsan +[test_ext_contentscript_xorigin_frame.js] +[test_WebExtensionContentScript.js] +[test_ext_ipcBlob.js] +skip-if = os == 'android' && processor == 'x86_64' diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/xpcshell.ini new file mode 100644 index 0000000000..27086a31ef --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini @@ -0,0 +1,89 @@ +[DEFAULT] +head = head.js head_telemetry.js head_sync.js head_storage.js +firefox-appdir = browser +dupe-manifest = +support-files = + data/** + xpcshell-content.ini +tags = webextensions in-process-webextensions + +# services.settings.server/default_bucket: +# Make sure that loading the default settings for url-classifier-skip-urls +# doesn't interfere with running our tests while IDB operations are in +# flight by overriding the default remote settings bucket pref name to +# ensure that the IDB database isn't created in the first place. +prefs = + services.settings.server=http://localhost:7777/remote-settings-dummy/v1 + services.settings.default_bucket=nonexistent-bucket-foo + +# This file contains tests which are not affected by multi-process +# configuration, or do not support out-of-process content or extensions +# for one reason or another. +# +# Tests which are affected by remote content or remote extensions should +# go in one of: +# +# - xpcshell-common.ini +# For tests which should run in all configurations. +# - xpcshell-common-e10s.ini +# For tests which should run in all configurations where e10s is enabled. +# - xpcshell-remote.ini +# For tests which should only run with both remote extensions and remote content. +# - xpcshell-content.ini +# For tests which rely on content pages, and should run in all configurations. +# - xpcshell-e10s.ini +# For tests which rely on content pages, and should only run with remote content +# but in-process extensions. + +[test_ExtensionStorageSync_migration_kinto.js] +skip-if = os == 'android' # Not shipped on Android +[test_MatchPattern.js] +[test_StorageSyncService.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_WebExtensionPolicy.js] + +[test_csp_custom_policies.js] +[test_csp_validator.js] +[test_ext_contexts.js] +[test_ext_json_parser.js] +[test_ext_geckoProfiler_schema.js] +skip-if = os == 'android' # Not shipped on Android +[test_ext_manifest.js] +skip-if = toolkit == 'android' # browser_action icon testing not supported on android +[test_ext_manifest_content_security_policy.js] +[test_ext_manifest_incognito.js] +[test_ext_indexedDB_principal.js] +[test_ext_manifest_minimum_chrome_version.js] +[test_ext_manifest_minimum_opera_version.js] +[test_ext_manifest_themes.js] +[test_ext_permission_warnings.js] +[test_ext_schemas.js] +[test_ext_schemas_roots.js] +[test_ext_schemas_async.js] +[test_ext_schemas_allowed_contexts.js] +[test_ext_schemas_interactive.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_schemas_manifest_permissions.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_schemas_privileged.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_schemas_revoke.js] +[test_ext_test_mock.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_test_wrapper.js] +[test_ext_unknown_permissions.js] +skip-if = os == 'android' && processor == 'x86_64' +[test_ext_webRequest_urlclassification.js] +[test_extension_permissions_migration.js] +[test_load_all_api_modules.js] +[test_locale_converter.js] +[test_locale_data.js] +skip-if = os == 'android' && processor == 'x86_64' + +[test_ext_runtime_sendMessage_args.js] +skip-if = os == 'android' && processor == 'x86_64' + +[include:xpcshell-common.ini] +run-if = os == 'android' # Android has no remote extensions, Bug 1535365 +[include:xpcshell-content.ini] +run-if = os == 'android' # Android has no remote extensions, Bug 1535365 |