From bbc9fae4291d0c2cb3976d158ecd20e0bd2a8ea0 Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Tue, 15 Mar 2022 10:12:06 +0100 Subject: serialize and deserialize entire Response object when relaying fetch() calls to other contexts using sendMessage --- background/CORS_bypass_server.js | 55 +++++++------- common/misc.js | 27 ++++--- content/repo_query_cacher.js | 11 +-- html/install.js | 57 ++++++++------- html/repo_query.js | 30 +++++--- html/repo_query_cacher_client.js | 85 ++++++++++++++++++++++ test/haketilo_test/unit/test_CORS_bypass_server.py | 65 +++++++++-------- test/haketilo_test/unit/test_install.py | 59 +++++++-------- test/haketilo_test/unit/test_popup.py | 22 ++---- test/haketilo_test/unit/test_repo_query.py | 64 ++++++++-------- test/haketilo_test/unit/test_repo_query_cacher.py | 19 +++-- test/haketilo_test/unit/utils.py | 58 ++++++++------- 12 files changed, 334 insertions(+), 218 deletions(-) create mode 100644 html/repo_query_cacher_client.js diff --git a/background/CORS_bypass_server.js b/background/CORS_bypass_server.js index 9805d41..d15db54 100644 --- a/background/CORS_bypass_server.js +++ b/background/CORS_bypass_server.js @@ -2,7 +2,7 @@ * This file is part of Haketilo. * * Function: Allow other parts of the extension to bypass CORS by routing their - * request through this background script using one-off messages. + * requests through this background script using one-off messages. * * Copyright (C) 2022 Wojtek Kosior * @@ -43,35 +43,40 @@ */ #FROM common/browser.js IMPORT browser +#FROM common/misc.js IMPORT uint8_to_hex, error_data_jsonifiable -async function get_prop(object, prop, result_object, call_prop=false) { - try { - result_object[prop] = call_prop ? (await object[prop]()) : object[prop]; - } catch(e) { - result_object[`error_${prop}`] = "" + e; - } +/* + * In this file we implement a fetch()-from-background-script service. Code in + * other parts of the extension shall call sendMessage() with arguments to + * fetch() and code here will call fetch() with those arguments and send back + * the response. + * + * We have to convert the Response from fetch() into a JSON-ifiable value and + * then convert it back (using Response() constructor) due (among others) the + * limitations of Chromium's messaging API: + * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#data_cloning_algorithm + * + * We also catch possible errors from fetch() (e.g. in case of an invalid URL) + * and send their data in JSON-ifiable form so that the error object can be + * later re-created. + */ + +/* Make it possible to serialize Response object. */ +async function response_data_jsonifiable(response) { + return { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + body: uint8_to_hex(new Uint8Array(await response.arrayBuffer())) + }; } -async function perform_download(fetch_data, respond_cb) { +async function perform_download(fetch_data) { try { - const response = await fetch(fetch_data.url); - const result = {}; - - for (const prop of (fetch_data.to_get || [])) - get_prop(response, prop, result); - - const to_call = (fetch_data.to_call || []); - const promises = []; - for (let i = 0; i < to_call.length; i++) { - const response_to_use = i === to_call.length - 1 ? - response : response.clone(); - promises.push(get_prop(response_to_use, to_call[i], result, true)); - } - - await Promise.all(promises); - return result; + const response = await fetch(fetch_data.url, fetch_data.init); + return response_data_jsonifiable(response); } catch(e) { - return {error: "" + e}; + return {error: error_data_jsonifiable(e)}; } } diff --git a/common/misc.js b/common/misc.js index e609fe0..8096f27 100644 --- a/common/misc.js +++ b/common/misc.js @@ -42,9 +42,13 @@ * proprietary program, I am not going to enforce this in court. */ -/* uint8_to_hex is a separate function used in cryptographic functions. */ +/* + * uint8_to_hex is a separate function used in cryptographic functions and when + * dealing with binary data. + */ const uint8_to_hex = array => [...array].map(b => ("0" + b.toString(16)).slice(-2)).join(""); +#EXPORT uint8_to_hex /* * Asynchronously compute hex string representation of a sha256 digest of a @@ -61,8 +65,7 @@ async function sha256_async(string) { * Generate a unique value that can be computed synchronously and is impossible * to guess for a malicious website. */ -function gen_nonce(length=16) -{ +function gen_nonce(length=16) { const random_data = new Uint8Array(length); crypto.getRandomValues(random_data); return uint8_to_hex(random_data); @@ -71,15 +74,9 @@ function gen_nonce(length=16) /* Check if some HTTP header might define CSP rules. */ const csp_header_regex = - /^\s*(content-security-policy|x-webkit-csp|x-content-security-policy)/i; + /^\s*(content-security-policy|x-webkit-csp|x-content-security-policy)\s*$/i; #EXPORT csp_header_regex -/* - * Print item together with type, e.g. - * nice_name("s", "hello") → "hello (script)" - */ -#EXPORT (prefix, name) => `${name} (${TYPE_NAME[prefix]})` AS nice_name - /* * Check if url corresponds to a browser's special page (or a directory index in * case of `file://' protocol). @@ -90,3 +87,13 @@ const priv_reg = /^moz-extension:\/\/|^about:|^view-source:|^file:\/\/[^?#]*\/([ const priv_reg = /^chrome(-extension)?:\/\/|^about:|^view-source:|^file:\/\/[^?#]*\/([?#]|$)/; #ENDIF #EXPORT url => priv_reg.test(url) AS is_privileged_url + +/* Make it possible to serialize en Error object. */ +function error_data_jsonifiable(error) { + const jsonifiable = {}; + for (const property of ["name", "message", "fileName", "lineNumber"]) + jsonifiable[property] = error[property]; + + return jsonifiable; +} +#EXPORT error_data_jsonifiable diff --git a/content/repo_query_cacher.js b/content/repo_query_cacher.js index 3f62be7..be97c39 100644 --- a/content/repo_query_cacher.js +++ b/content/repo_query_cacher.js @@ -43,10 +43,12 @@ */ #FROM common/browser.js IMPORT browser +#FROM common/misc.js IMPORT error_data_jsonifiable /* * Map URLs to objects containing parsed responses, error info or promises - * resolving to those. + * resolving to those. The use of promises helps us prevent multiple requests + * for the same resource from starting concurrently. */ const cache = new Map(); @@ -58,12 +60,11 @@ async function perform_download(url) { cache.set(url, new Promise(cb => resolve_cb = cb)); try { - const opts = {url, to_get: ["ok", "status"], to_call: ["json"]}; - var result = await browser.runtime.sendMessage(["CORS_bypass", opts]); + var result = await browser.runtime.sendMessage(["CORS_bypass", {url}]); if (result === undefined) - result = {error: "Couldn't communicate with background script."}; + throw new Error("Couldn't communicate with background script."); } catch(e) { - var result = {error: e + ""}; + return {error: error_data_jsonifiable(e)}; } cache.set(url, result); diff --git a/html/install.js b/html/install.js index fddee5d..a066b9b 100644 --- a/html/install.js +++ b/html/install.js @@ -51,6 +51,8 @@ #FROM common/misc.js IMPORT sha256_async AS compute_sha256 #FROM common/jsonschema.js IMPORT haketilo_validator, haketilo_schemas +#FROM html/repo_query_cacher_client.js IMPORT indirect_fetch + const coll = new Intl.Collator(); /* @@ -104,6 +106,9 @@ async function init_work() { }; work.err = function (error, user_message) { + if (!this.is_ok) + return; + if (error) console.error("Haketilo:", error); work.is_ok = false; @@ -171,35 +176,40 @@ function InstallView(tab_id, on_view_show, on_view_hide) { const url = ver ? `${this.repo_url}${item_type}/${id}/${ver.join(".")}` : `${this.repo_url}${item_type}/${id}.json`; - const response = - await browser.tabs.sendMessage(tab_id, ["repo_query", url]); - if (!work.is_ok) - return; - if ("error" in response) { - return work.err(response.error, - "Failure to communicate with repository :("); + + try { + var response = await indirect_fetch(tab_id, url); + } catch(e) { + return work.err(e, "Failure to communicate with repository :("); } + if (!work.is_ok) + return; + if (!response.ok) { return work.err(null, `Repository sent HTTP code ${response.status} :(`); } - if ("error_json" in response) { - return work.err(response.error_json, - "Repository's response is not valid JSON :("); + try { + var json = await response.json(); + } catch(e) { + return work.err(e, "Repository's response is not valid JSON :("); } + if (!work.is_ok) + return; + const captype = item_type[0].toUpperCase() + item_type.substring(1); const $id = `https://hydrilla.koszko.org/schemas/api_${item_type}_description-1.0.1.schema.json`; const schema = haketilo_schemas[$id]; - const result = haketilo_validator.validate(response.json, schema); + const result = haketilo_validator.validate(json, schema); if (result.errors.length > 0) { const reg = new RegExp(schema.allOf[2].properties.$schema.pattern); - if (response.json.$schema && !reg.test(response.json.$schema)) { + if (json.$schema && !reg.test(json.$schema)) { const msg = `${captype} ${item_id_string(id, ver)} was served using unsupported Hydrilla API version. You might need to update Haketilo.`; return work.err(result.errors, msg); } @@ -208,14 +218,14 @@ function InstallView(tab_id, on_view_show, on_view_hide) { return work.err(result.errors, msg); } - const scripts = item_type === "resource" && response.json.scripts; - const files = response.json.source_copyright.concat(scripts || []); + const scripts = item_type === "resource" && json.scripts; + const files = json.source_copyright.concat(scripts || []); if (item_type === "mapping") { - for (const res_ref of Object.values(response.json.payloads || {})) + for (const res_ref of Object.values(json.payloads || {})) process_item(work, "resource", res_ref.identifier); } else { - for (const res_ref of (response.json.dependencies || [])) + for (const res_ref of (json.dependencies || [])) process_item(work, "resource", res_ref.identifier); } @@ -234,8 +244,8 @@ function InstallView(tab_id, on_view_show, on_view_hide) { const msg = "Error accessing Haketilo's internal database :("; return work.err(e, msg); } - if (!db_def || db_def.version < response.json.version) - work.result.push({def: response.json, db_def}); + if (!db_def || db_def.version < json.version) + work.result.push({def: json, db_def}); if (--work.waiting === 0) work.resolve_cb(work.result); @@ -320,14 +330,9 @@ function InstallView(tab_id, on_view_show, on_view_hide) { return work.err(null, msg); } - try { - var text = await response.text(); - if (!work.is_ok) - return; - } catch(e) { - const msg = "Repository's response is not valid text :("; - return work.err(e, msg); - } + const text = await response.text(); + if (!work.is_ok) + return; const digest = await compute_sha256(text); if (!work.is_ok) diff --git a/html/repo_query.js b/html/repo_query.js index 601a0aa..97f60ac 100644 --- a/html/repo_query.js +++ b/html/repo_query.js @@ -49,6 +49,8 @@ #FROM html/install.js IMPORT InstallView #FROM common/jsonschema.js IMPORT haketilo_validator, haketilo_schemas +#FROM html/repo_query_cacher_client.js IMPORT indirect_fetch + const coll = new Intl.Collator(); function ResultEntry(repo_entry, mapping_ref) { @@ -76,36 +78,42 @@ function RepoEntry(query_view, repo_url) { this.repo_url_label.innerText = repo_url; - const query_results = async () => { - const msg = [ - "repo_query", - `${repo_url}query?url=${encodeURIComponent(query_view.url)}` - ]; - const response = await browser.tabs.sendMessage(query_view.tab_id, msg); + const encoded_queried_url = encodeURIComponent(query_view.url); + const query_url = `${repo_url}query?url=${encoded_queried_url}`; - if ("error" in response) + const query_results = async () => { + try { + var response = await indirect_fetch(query_view.tab_id, query_url); + } catch(e) { + console.error("Haketilo:", e); throw "Failure to communicate with repository :("; + } if (!response.ok) throw `Repository sent HTTP code ${response.status} :(`; - if ("error_json" in response) + + try { + var json = await response.json(); + } catch(e) { + console.error("Haketilo:", e); throw "Repository's response is not valid JSON :("; + } const $id = `https://hydrilla.koszko.org/schemas/api_query_result-1.0.1.schema.json`; const schema = haketilo_schemas[$id]; - const result = haketilo_validator.validate(response.json, schema); + const result = haketilo_validator.validate(json, schema); if (result.errors.length > 0) { console.error("Haketilo:", result.errors); const reg = new RegExp(schema.properties.$schema.pattern); - if (response.json.$schema && !reg.test(response.json.$schema)) + if (json.$schema && !reg.test(json.$schema)) throw "Results were served using unsupported Hydrilla API version. You might need to update Haketilo."; throw "Results were served using a nonconforming response format."; } - return response.json.mappings; + return json.mappings; } const populate_results = async () => { diff --git a/html/repo_query_cacher_client.js b/html/repo_query_cacher_client.js new file mode 100644 index 0000000..b7f49e9 --- /dev/null +++ b/html/repo_query_cacher_client.js @@ -0,0 +1,85 @@ +/** + * This file is part of Haketilo. + * + * Function: Making requests to remote repositories through a response cache in + * operating in the content script of a browser tab. + * + * Copyright (C) 2022 Wojtek Kosior + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * As additional permission under GNU GPL version 3 section 7, you + * may distribute forms of that code without the copy of the GNU + * GPL normally required by section 4, provided you include this + * license notice and, in case of non-source distribution, a URL + * through which recipients can access the Corresponding Source. + * If you modify file(s) with this exception, you may extend this + * exception to your version of the file(s), but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * As a special exception to the GPL, any HTML file which merely + * makes function calls to this code, and for that purpose + * includes it by reference shall be deemed a separate work for + * copyright law purposes. If you modify this code, you may extend + * this exception to your version of the code, but you are not + * obligated to do so. If you do not wish to do so, delete this + * exception statement from your version. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * I, Wojtek Kosior, thereby promise not to sue for violation of this file's + * license. Although I request that you do not make use of this code in a + * proprietary program, I am not going to enforce this in court. + */ + +#FROM common/browser.js IMPORT browser + +/* + * This is a complementary function to error_data_jsonifiable() in + * common/misc.js. + */ +function error_from_jsonifiable_data(jsonifiable) { + const arg_props = ["message", "fileName", "lineNumber"]; + return new window[jsonifiable.name](...arg_props.map(p => jsonifiable[p])); +} + +/* + * Make it possible to recover a Response object. This is a complementary + * function to response_data_jsonifiable() in background/CORS_bypass_server.js. + */ +function response_from_jsonifiable_data(jsonifiable) { + const body = jsonifiable.body, body_len = body.length / 2; + const body_buf = new Uint8Array(body_len); + + for (let i = 0; i < body_len; i++) + body_buf[i] = parseInt(`0x${body.substring(i * 2, i * 2 + 2)}`); + + const init = { + status: jsonifiable.status, + statusText: jsonifiable.statusText, + headers: new Headers(jsonifiable.headers) + }; + + return new Response(body_buf, init); +} + +async function indirect_fetch(tab_id, url) { + const msg = ["repo_query", url]; + const jsonifiable = await browser.tabs.sendMessage(tab_id, msg); + + if ("error" in jsonifiable) + throw error_from_jsonifiable_data(jsonifiable); + + return response_from_jsonifiable_data(jsonifiable); +} +#EXPORT indirect_fetch diff --git a/test/haketilo_test/unit/test_CORS_bypass_server.py b/test/haketilo_test/unit/test_CORS_bypass_server.py index 45e4ebb..45f06a9 100644 --- a/test/haketilo_test/unit/test_CORS_bypass_server.py +++ b/test/haketilo_test/unit/test_CORS_bypass_server.py @@ -24,29 +24,28 @@ from selenium.webdriver.support.ui import WebDriverWait from ..script_loader import load_script from ..world_wide_library import some_data -urls = { - 'resource': 'https://anotherdoma.in/resource/blocked/by/CORS.json', - 'nonexistent': 'https://nxdoma.in/resource.json', - 'invalid': 'w3csucks://invalid.url/' +datas = { + 'resource': 'https://anotherdoma.in/resource/blocked/by/CORS.json', + 'nonexistent': 'https://nxdoma.in/resource.json', + 'invalid': 'w3csucks://invalid.url/', + 'redirected_ok': 'https://site.with.scripts.block.ed', + 'redirected_err': 'https://site.with.scripts.block.ed' } +for name, url in [*datas.items()]: + datas[name] = {'url': url} + +datas['redirected_ok']['init'] = {'redirect': 'follow'} +datas['redirected_err']['init'] = {'redirect': 'error'} + content_script = '''\ -const urls = %s; - -function fetch_data(url) { - return { - url, - to_get: ["ok", "status"], - to_call: ["text", "json"] - }; -} +const datas = %s; async function fetch_resources() { const results = {}; const promises = []; - for (const [name, url] of Object.entries(urls)) { - const sending = browser.runtime.sendMessage(["CORS_bypass", - fetch_data(url)]); + for (const [name, data] of Object.entries(datas)) { + const sending = browser.runtime.sendMessage(["CORS_bypass", data]); promises.push(sending.then(response => results[name] = response)); } @@ -58,7 +57,7 @@ async function fetch_resources() { fetch_resources(); ''' -content_script = content_script % json.dumps(urls); +content_script = content_script % json.dumps(datas); @pytest.mark.ext_data({ 'content_script': content_script, @@ -77,33 +76,41 @@ def test_CORS_bypass_server(driver, execute_in_page): ''' const result = {}; let promises = []; - for (const [name, url] of Object.entries(arguments[0])) { + for (const [name, data] of Object.entries(arguments[0])) { const [ok_cb, err_cb] = ["ok", "err"].map(status => () => result[name] = status); - promises.push(fetch(url).then(ok_cb, err_cb)); + promises.push(fetch(data.url).then(ok_cb, err_cb)); } // Make the promises non-failing. promises = promises.map(p => new Promise(cb => p.then(cb, cb))); returnval(Promise.all(promises).then(() => result)); ''', - {**urls, 'sameorigin': './nonexistent_resource'}) + {**datas, 'sameorigin': './nonexistent_resource'}) - assert results == dict([*[(k, 'err') for k in urls.keys()], + assert results == dict([*[(k, 'err') for k in datas.keys()], ('sameorigin', 'ok')]) done = lambda d: d.execute_script('return window.haketilo_fetch_results;') results = WebDriverWait(driver, 10).until(done) assert set(results['invalid'].keys()) == {'error'} + assert results['invalid']['error']['fileName'].endswith('background.js') + assert type(results['invalid']['error']['lineNumber']) is int + assert type(results['invalid']['error']['message']) is str + assert results['invalid']['error']['name'] == 'TypeError' - assert set(results['nonexistent'].keys()) == \ - {'ok', 'status', 'text', 'error_json'} - assert results['nonexistent']['ok'] == False assert results['nonexistent']['status'] == 404 - assert results['nonexistent']['text'] == 'Handler for this URL not found.' + assert results['nonexistent']['statusText'] == 'Not Found' + assert any([name.lower() == 'content-length' + for name, value in results['nonexistent']['headers']]) + assert bytes.fromhex(results['nonexistent']['body']) == \ + b'Handler for this URL not found.' - assert set(results['resource'].keys()) == {'ok', 'status', 'text', 'json'} - assert results['resource']['ok'] == True assert results['resource']['status'] == 200 - assert results['resource']['text'] == some_data - assert results['resource']['json'] == json.loads(some_data) + assert results['resource']['statusText'] == 'OK' + assert any([name.lower() == 'content-length' + for name, value in results['resource']['headers']]) + assert bytes.fromhex(results['resource']['body']) == b'{"some": "data"}' + + assert results['redirected_ok']['status'] == 200 + assert results['redirected_err']['error']['name'] == 'TypeError' diff --git a/test/haketilo_test/unit/test_install.py b/test/haketilo_test/unit/test_install.py index 1e2063c..29910cf 100644 --- a/test/haketilo_test/unit/test_install.py +++ b/test/haketilo_test/unit/test_install.py @@ -26,7 +26,7 @@ from ..script_loader import load_script from .utils import * def setup_view(driver, execute_in_page): - mock_cacher(execute_in_page) + execute_in_page(mock_cacher_code) execute_in_page(load_script('html/install.js')) container_ids, containers_objects = execute_in_page( @@ -203,7 +203,6 @@ def test_install_normal_usage(driver, execute_in_page, complex_variant): 'indexeddb_error_file_uses', 'failure_to_communicate_fetch', 'HTTP_code_file', - 'not_valid_text', 'sha256_mismatch', 'indexeddb_error_write' ]) @@ -243,7 +242,7 @@ def test_install_dialogs(driver, execute_in_page, message): if message == 'fetching_data': execute_in_page( ''' - browser.tabs.sendMessage = () => new Promise(cb => {}); + window.mock_cacher_fetch = () => new Promise(cb => {}); install_view.show(...arguments); ''', 'https://hydril.la/', 'mapping', 'mapping-a') @@ -253,7 +252,8 @@ def test_install_dialogs(driver, execute_in_page, message): elif message == 'failure_to_communicate_sendmessage': execute_in_page( ''' - browser.tabs.sendMessage = () => Promise.resolve({error: "sth"}); + window.mock_cacher_fetch = + () => {throw new Error("Something happened :o")}; install_view.show(...arguments); ''', 'https://hydril.la/', 'mapping', 'mapping-a') @@ -262,8 +262,8 @@ def test_install_dialogs(driver, execute_in_page, message): elif message == 'HTTP_code_item': execute_in_page( ''' - const response = {ok: false, status: 404}; - browser.tabs.sendMessage = () => Promise.resolve(response); + const response = new Response("", {status: 404}); + window.mock_cacher_fetch = () => Promise.resolve(response); install_view.show(...arguments); ''', 'https://hydril.la/', 'mapping', 'mapping-a') @@ -272,8 +272,8 @@ def test_install_dialogs(driver, execute_in_page, message): elif message == 'invalid_JSON': execute_in_page( ''' - const response = {ok: true, status: 200, error_json: "sth"}; - browser.tabs.sendMessage = () => Promise.resolve(response); + const response = new Response("sth", {status: 200}); + window.mock_cacher_fetch = () => Promise.resolve(response); install_view.show(...arguments); ''', 'https://hydril.la/', 'mapping', 'mapping-a') @@ -282,12 +282,11 @@ def test_install_dialogs(driver, execute_in_page, message): elif message == 'newer_API_version': execute_in_page( ''' - const old_sendMessage = browser.tabs.sendMessage; - browser.tabs.sendMessage = async function(...args) { - const response = await old_sendMessage(...args); - response.json.$schema = "https://hydrilla.koszko.org/schemas/api_mapping_description-255.1.schema.json"; - return response; - } + const newer_schema_url = + "https://hydrilla.koszko.org/schemas/api_mapping_description-255.1.schema.json"; + const mocked_json_data = JSON.stringify({$schema: newer_schema_url}); + const response = new Response(mocked_json_data, {status: 200}); + window.mock_cacher_fetch = () => Promise.resolve(response); install_view.show(...arguments); ''', 'https://hydril.la/', 'mapping', 'mapping-a', [2022, 5, 10]) @@ -297,12 +296,18 @@ def test_install_dialogs(driver, execute_in_page, message): elif message == 'invalid_response_format': execute_in_page( ''' - const old_sendMessage = browser.tabs.sendMessage; - browser.tabs.sendMessage = async function(...args) { - const response = await old_sendMessage(...args); - /* identifier is not a string as it should be. */ - response.json.identifier = 1234567; - return response; + window.mock_cacher_fetch = async function(...args) { + const response = await fetch(...args); + const json = await response.json(); + + /* identifier is no longer a string as it should be. */ + json.identifier = 1234567; + + return new Response(JSON.stringify(json), { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()] + }); } install_view.show(...arguments); ''', @@ -352,7 +357,7 @@ def test_install_dialogs(driver, execute_in_page, message): elif message == 'failure_to_communicate_fetch': execute_in_page( ''' - fetch = () => {throw "some error";}; + fetch = () => {throw new Error("some error");}; returnval(install_view.show(...arguments)); ''', 'https://hydril.la/', 'mapping', 'mapping-b') @@ -372,18 +377,6 @@ def test_install_dialogs(driver, execute_in_page, message): execute_in_page('returnval(install_view.install_but);').click() assert_dlg(['conf_buts'], 'Repository sent HTTP code 400 :(') - elif message == 'not_valid_text': - execute_in_page( - ''' - const err = () => {throw "some error";}; - fetch = () => Promise.resolve({ok: true, status: 200, text: err}); - returnval(install_view.show(...arguments)); - ''', - 'https://hydril.la/', 'mapping', 'mapping-b') - - execute_in_page('returnval(install_view.install_but);').click() - - assert_dlg(['conf_buts'], "Repository's response is not valid text :(") elif message == 'sha256_mismatch': execute_in_page( ''' diff --git a/test/haketilo_test/unit/test_popup.py b/test/haketilo_test/unit/test_popup.py index e62feb7..3ef7906 100644 --- a/test/haketilo_test/unit/test_popup.py +++ b/test/haketilo_test/unit/test_popup.py @@ -81,28 +81,18 @@ mocked_page_infos = { tab_mock_js = ''' ; const mocked_page_info = (%s)[/#mock_page_info-(.*)$/.exec(document.URL)[1]]; +const old_sendMessage = browser.tabs.sendMessage; browser.tabs.sendMessage = async function(tab_id, msg) { const this_tab_id = (await browser.tabs.getCurrent()).id; if (tab_id !== this_tab_id) throw `not current tab id (${tab_id} instead of ${this_tab_id})`; - if (msg[0] === "page_info") { + if (msg[0] === "page_info") return mocked_page_info; - } else if (msg[0] === "repo_query") { - const response = await fetch(msg[1]); - if (!response) - return {error: "Something happened :o"}; - - const result = {ok: response.ok, status: response.status}; - try { - result.json = await response.json(); - } catch(e) { - result.error_json = "" + e; - } - return result; - } else { + else if (msg[0] === "repo_query") + return old_sendMessage(tab_id, msg); + else throw `bad sendMessage message type: '${msg[0]}'`; - } } const old_tabs_query = browser.tabs.query; @@ -113,6 +103,8 @@ browser.tabs.query = async function(query) { } ''' % json.dumps(mocked_page_infos) +tab_mock_js = mock_cacher_code + tab_mock_js + popup_ext_data = { 'background_script': broker_js, 'extra_html': ExtraHTML( diff --git a/test/haketilo_test/unit/test_repo_query.py b/test/haketilo_test/unit/test_repo_query.py index f6cae93..c785406 100644 --- a/test/haketilo_test/unit/test_repo_query.py +++ b/test/haketilo_test/unit/test_repo_query.py @@ -29,7 +29,7 @@ repo_urls = [f'https://hydril.la/{s}' for s in ('', '1/', '2/', '3/', '4/')] queried_url = 'https://example_a.com/something' def setup_view(execute_in_page, repo_urls, tab={'id': 0}): - mock_cacher(execute_in_page) + execute_in_page(mock_cacher_code) execute_in_page(load_script('html/repo_query.js')) execute_in_page( @@ -185,8 +185,10 @@ def test_repo_query_messages(driver, execute_in_page, message): elif message == 'failure_to_communicate': setup_view(execute_in_page, repo_urls) execute_in_page( - 'browser.tabs.sendMessage = () => Promise.resolve({error: "sth"});' - ) + ''' + window.mock_cacher_fetch = + () => {throw new Error("Something happened :o")}; + ''') show_and_wait_for_repo_entry() elem = execute_in_page('returnval(view.repo_entries[0].info_div);') @@ -196,8 +198,8 @@ def test_repo_query_messages(driver, execute_in_page, message): setup_view(execute_in_page, repo_urls) execute_in_page( ''' - const response = {ok: false, status: 405}; - browser.tabs.sendMessage = () => Promise.resolve(response); + const response = new Response("", {status: 405}); + window.mock_cacher_fetch = () => Promise.resolve(response); ''') show_and_wait_for_repo_entry() @@ -208,8 +210,8 @@ def test_repo_query_messages(driver, execute_in_page, message): setup_view(execute_in_page, repo_urls) execute_in_page( ''' - const response = {ok: true, status: 200, error_json: "sth"}; - browser.tabs.sendMessage = () => Promise.resolve(response); + const response = new Response("sth", {status: 200}); + window.mock_cacher_fetch = () => Promise.resolve(response); ''') show_and_wait_for_repo_entry() @@ -220,12 +222,11 @@ def test_repo_query_messages(driver, execute_in_page, message): setup_view(execute_in_page, repo_urls) execute_in_page( ''' - const response = { - ok: true, - status: 200, - json: {$schema: "https://hydrilla.koszko.org/schemas/api_query_result-255.2.1.schema.json"} - }; - browser.tabs.sendMessage = () => Promise.resolve(response); + const newer_schema_url = + "https://hydrilla.koszko.org/schemas/api_query_result-255.2.1.schema.json"; + const mocked_json_data = JSON.stringify({$schema: newer_schema_url}); + const response = new Response(mocked_json_data, {status: 200}); + window.mock_cacher_fetch = () => Promise.resolve(response); ''') show_and_wait_for_repo_entry() @@ -236,13 +237,19 @@ def test_repo_query_messages(driver, execute_in_page, message): setup_view(execute_in_page, repo_urls) execute_in_page( ''' - const response = { - ok: true, - status: 200, - /* $schema is not a string as it should be. */ - json: {$schema: null} - }; - browser.tabs.sendMessage = () => Promise.resolve(response); + window.mock_cacher_fetch = async function(...args) { + const response = await fetch(...args); + const json = await response.json(); + + /* $schema is no longer a string as it should be. */ + json.$schema = null; + + return new Response(JSON.stringify(json), { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()] + }); + } ''') show_and_wait_for_repo_entry() @@ -252,7 +259,7 @@ def test_repo_query_messages(driver, execute_in_page, message): elif message == 'querying_repo': setup_view(execute_in_page, repo_urls) execute_in_page( - 'browser.tabs.sendMessage = () => new Promise(() => {});' + 'window.mock_cacher_fetch = () => new Promise(cb => {});' ) show_and_wait_for_repo_entry() @@ -262,15 +269,12 @@ def test_repo_query_messages(driver, execute_in_page, message): setup_view(execute_in_page, repo_urls) execute_in_page( ''' - const response = { - ok: true, - status: 200, - json: { - $schema: "https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json", - mappings: [] - } - }; - browser.tabs.sendMessage = () => Promise.resolve(response); + const schema_url = + "https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json"; + const mocked_json_data = + JSON.stringify({$schema: schema_url, mappings: []}); + const response = new Response(mocked_json_data, {status: 200}); + window.mock_cacher_fetch = () => Promise.resolve(response); ''') show_and_wait_for_repo_entry() diff --git a/test/haketilo_test/unit/test_repo_query_cacher.py b/test/haketilo_test/unit/test_repo_query_cacher.py index 5fbc5cd..3f0a00d 100644 --- a/test/haketilo_test/unit/test_repo_query_cacher.py +++ b/test/haketilo_test/unit/test_repo_query_cacher.py @@ -85,34 +85,35 @@ def run_content_script_in_new_window(driver, url): 'background_script': lambda: bypass_js() + ';' + tab_id_responder }) @pytest.mark.usefixtures('webextension') -def test_repo_query_cacher_normal_use(driver, execute_in_page): +def test_repo_query_cacher_normal_use(driver): """ Test if HTTP requests made through our cacher return correct results. """ tab_id = run_content_script_in_new_window(driver, 'https://gotmyowndoma.in') result = fetch_through_cache(driver, tab_id, 'https://counterdoma.in/') - assert set(result.keys()) == {'ok', 'status', 'json'} - counter_initial = result['json']['counter'] + assert set(result.keys()) == {'status', 'statusText', 'headers', 'body'} + counter_initial = json.loads(bytes.fromhex(result['body']))['counter'] assert type(counter_initial) is int for i in range(2): result = fetch_through_cache(driver, tab_id, 'https://counterdoma.in/') - assert result['json']['counter'] == counter_initial + assert json.loads(bytes.fromhex(result['body'])) \ + == {'counter': counter_initial} tab_id = run_content_script_in_new_window(driver, 'https://gotmyowndoma.in') result = fetch_through_cache(driver, tab_id, 'https://counterdoma.in/') - assert result['json']['counter'] == counter_initial + 1 + assert json.loads(bytes.fromhex(result['body'])) \ + == {'counter': counter_initial + 1} for i in range(2): result = fetch_through_cache(driver, tab_id, 'https://nxdoma.in/') - assert set(result.keys()) == {'ok', 'status', 'error_json'} - assert result['ok'] == False assert result['status'] == 404 for i in range(2): result = fetch_through_cache(driver, tab_id, 'bad://url') assert set(result.keys()) == {'error'} + assert result['error']['name'] == 'TypeError' @pytest.mark.ext_data({ 'content_script': content_script, @@ -128,3 +129,7 @@ def test_repo_query_cacher_bgscript_error(driver): result = fetch_through_cache(driver, tab_id, 'https://counterdoma.in/') assert set(result.keys()) == {'error'} + assert set(result['error'].keys()) == \ + {'name', 'message', 'fileName', 'lineNumber'} + assert result['error']['message'] == \ + "Couldn't communicate with background script." diff --git a/test/haketilo_test/unit/utils.py b/test/haketilo_test/unit/utils.py index 7ddf92a..a49ce8c 100644 --- a/test/haketilo_test/unit/utils.py +++ b/test/haketilo_test/unit/utils.py @@ -246,36 +246,40 @@ def mock_broadcast(execute_in_page): 'Object.keys(broadcast).forEach(k => broadcast[k] = () => {});' ) -def mock_cacher(execute_in_page): - """ - Some parts of code depend on content/repo_query_cacher.js and - background/CORS_bypass_server.js running in their appropriate contexts. This - function modifies the relevant browser.runtime.sendMessage function to - perform fetch(), bypassing the cacher. - """ - execute_in_page( - '''{ - const old_sendMessage = browser.tabs.sendMessage, old_fetch = fetch; - async function new_sendMessage(tab_id, msg) { - if (msg[0] !== "repo_query") - return old_sendMessage(tab_id, msg); +""" +Some parts of code depend on content/repo_query_cacher.js and +background/CORS_bypass_server.js running in their appropriate contexts. This +snippet modifies the relevant browser.runtime.sendMessage function to perform +fetch(), bypassing the cacher. +""" +mock_cacher_code = '''{ +const uint8_to_hex = + array => [...array].map(b => ("0" + b.toString(16)).slice(-2)).join(""); - /* Use snapshotted fetch(), allow other test code to override it. */ - const response = await old_fetch(msg[1]); - if (!response) - return {error: "Something happened :o"}; +const old_sendMessage = browser.tabs.sendMessage; +window.mock_cacher_fetch = fetch; +browser.tabs.sendMessage = async function(tab_id, msg) { + if (msg[0] !== "repo_query") + return old_sendMessage(tab_id, msg); - const result = {ok: response.ok, status: response.status}; - try { - result.json = await response.json(); - } catch(e) { - result.error_json = "" + e; - } - return result; + /* + * Use snapshotted fetch() under the name window.mock_cacher_fetch, + * allow other test code to override it. + */ + try { + const response = await window.mock_cacher_fetch(msg[1]); + const buf = await response.arrayBuffer(); + return { + status: response.status, + statusText: response.statusText, + headers: [...response.headers.entries()], + body: uint8_to_hex(new Uint8Array(buf)) } - - browser.tabs.sendMessage = new_sendMessage; - }''') + } catch(e) { + return {error: {name: e.name, message: e.message}}; + } +} +}''' """ Convenience snippet of code to retrieve a copy of given object with only those -- cgit v1.2.3