Project

General

Profile

« Previous | Next » 

Revision bbc9fae4

Added by koszko over 1 year ago

serialize and deserialize entire Response object when relaying fetch() calls to other contexts using sendMessage

View differences:

background/CORS_bypass_server.js
2 2
 * This file is part of Haketilo.
3 3
 *
4 4
 * Function: Allow other parts of the extension to bypass CORS by routing their
5
 *           request through this background script using one-off messages.
5
 *           requests through this background script using one-off messages.
6 6
 *
7 7
 * Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
8 8
 *
......
43 43
 */
44 44

  
45 45
#FROM common/browser.js IMPORT browser
46
#FROM common/misc.js    IMPORT uint8_to_hex, error_data_jsonifiable
46 47

  
47
async function get_prop(object, prop, result_object, call_prop=false) {
48
    try {
49
	result_object[prop] = call_prop ? (await object[prop]()) : object[prop];
50
    } catch(e) {
51
	result_object[`error_${prop}`] = "" + e;
52
    }
48
/*
49
 * In this file we implement a fetch()-from-background-script service. Code in
50
 * other parts of the extension shall call sendMessage() with arguments to
51
 * fetch() and code here will call fetch() with those arguments and send back
52
 * the response.
53
 *
54
 * We have to convert the Response from fetch() into a JSON-ifiable value and
55
 * then convert it back (using Response() constructor) due (among others) the
56
 * limitations of Chromium's messaging API:
57
 * https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Chrome_incompatibilities#data_cloning_algorithm
58
 *
59
 * We also catch possible errors from fetch() (e.g. in case of an invalid URL)
60
 * and send their data in JSON-ifiable form so that the error object can be
61
 * later re-created.
62
 */
63

  
64
/* Make it possible to serialize Response object. */
65
async function response_data_jsonifiable(response) {
66
    return {
67
	status:     response.status,
68
	statusText: response.statusText,
69
	headers:    [...response.headers.entries()],
70
	body:       uint8_to_hex(new Uint8Array(await response.arrayBuffer()))
71
    };
53 72
}
54 73

  
55
async function perform_download(fetch_data, respond_cb) {
74
async function perform_download(fetch_data) {
56 75
    try {
57
	const response = await fetch(fetch_data.url);
58
	const result = {};
59

  
60
	for (const prop of (fetch_data.to_get || []))
61
	    get_prop(response, prop, result);
62

  
63
	const to_call = (fetch_data.to_call || []);
64
	const promises = [];
65
	for (let i = 0; i < to_call.length; i++) {
66
	    const response_to_use = i === to_call.length - 1 ?
67
		response : response.clone();
68
	    promises.push(get_prop(response_to_use, to_call[i], result, true));
69
	}
70

  
71
	await Promise.all(promises);
72
	return result;
76
	const response = await fetch(fetch_data.url, fetch_data.init);
77
	return response_data_jsonifiable(response);
73 78
    } catch(e) {
74
	return {error: "" + e};
79
	return {error: error_data_jsonifiable(e)};
75 80
    }
76 81
}
77 82

  
common/misc.js
42 42
 * proprietary program, I am not going to enforce this in court.
43 43
 */
44 44

  
45
/* uint8_to_hex is a separate function used in cryptographic functions. */
45
/*
46
 * uint8_to_hex is a separate function used in cryptographic functions and when
47
 * dealing with binary data.
48
 */
46 49
const uint8_to_hex =
47 50
      array => [...array].map(b => ("0" + b.toString(16)).slice(-2)).join("");
51
#EXPORT uint8_to_hex
48 52

  
49 53
/*
50 54
 * Asynchronously compute hex string representation of a sha256 digest of a
......
61 65
 * Generate a unique value that can be computed synchronously and is impossible
62 66
 * to guess for a malicious website.
63 67
 */
64
function gen_nonce(length=16)
65
{
68
function gen_nonce(length=16) {
66 69
    const random_data = new Uint8Array(length);
67 70
    crypto.getRandomValues(random_data);
68 71
    return uint8_to_hex(random_data);
......
71 74

  
72 75
/* Check if some HTTP header might define CSP rules. */
73 76
const csp_header_regex =
74
      /^\s*(content-security-policy|x-webkit-csp|x-content-security-policy)/i;
77
      /^\s*(content-security-policy|x-webkit-csp|x-content-security-policy)\s*$/i;
75 78
#EXPORT csp_header_regex
76 79

  
77
/*
78
 * Print item together with type, e.g.
79
 * nice_name("s", "hello") → "hello (script)"
80
 */
81
#EXPORT  (prefix, name) => `${name} (${TYPE_NAME[prefix]})`  AS nice_name
82

  
83 80
/*
84 81
 * Check if url corresponds to a browser's special page (or a directory index in
85 82
 * case of `file://' protocol).
......
90 87
const priv_reg = /^chrome(-extension)?:\/\/|^about:|^view-source:|^file:\/\/[^?#]*\/([?#]|$)/;
91 88
#ENDIF
92 89
#EXPORT  url => priv_reg.test(url)  AS is_privileged_url
90

  
91
/* Make it possible to serialize en Error object. */
92
function error_data_jsonifiable(error) {
93
    const jsonifiable = {};
94
    for (const property of ["name", "message", "fileName", "lineNumber"])
95
	jsonifiable[property] = error[property];
96

  
97
    return jsonifiable;
98
}
99
#EXPORT error_data_jsonifiable
content/repo_query_cacher.js
43 43
 */
44 44

  
45 45
#FROM common/browser.js IMPORT browser
46
#FROM common/misc.js    IMPORT error_data_jsonifiable
46 47

  
47 48
/*
48 49
 * Map URLs to objects containing parsed responses, error info or promises
49
 * resolving to those.
50
 * resolving to those. The use of promises helps us prevent multiple requests
51
 * for the same resource from starting concurrently.
50 52
 */
51 53
const cache = new Map();
52 54

  
......
58 60
    cache.set(url, new Promise(cb => resolve_cb = cb));
59 61

  
60 62
    try {
61
	const opts = {url, to_get: ["ok", "status"], to_call: ["json"]};
62
	var result = await browser.runtime.sendMessage(["CORS_bypass", opts]);
63
	var result = await browser.runtime.sendMessage(["CORS_bypass", {url}]);
63 64
	if (result === undefined)
64
	    result = {error: "Couldn't communicate with background script."};
65
	    throw new Error("Couldn't communicate with background script.");
65 66
    } catch(e) {
66
	var result = {error: e + ""};
67
	return {error: error_data_jsonifiable(e)};
67 68
    }
68 69

  
69 70
    cache.set(url, result);
html/install.js
51 51
#FROM common/misc.js       IMPORT sha256_async AS compute_sha256
52 52
#FROM common/jsonschema.js IMPORT haketilo_validator, haketilo_schemas
53 53

  
54
#FROM html/repo_query_cacher_client.js IMPORT indirect_fetch
55

  
54 56
const coll = new Intl.Collator();
55 57

  
56 58
/*
......
104 106
    };
105 107

  
106 108
    work.err = function (error, user_message) {
109
	if (!this.is_ok)
110
	    return;
111

  
107 112
	if (error)
108 113
	    console.error("Haketilo:", error);
109 114
	work.is_ok = false;
......
171 176
	const url = ver ?
172 177
	      `${this.repo_url}${item_type}/${id}/${ver.join(".")}` :
173 178
	      `${this.repo_url}${item_type}/${id}.json`;
174
	const response =
175
	      await browser.tabs.sendMessage(tab_id, ["repo_query", url]);
176
	if (!work.is_ok)
177
	    return;
178 179

  
179
	if ("error" in response) {
180
	    return work.err(response.error,
181
			    "Failure to communicate with repository :(");
180

  
181
	try {
182
	    var response = await indirect_fetch(tab_id, url);
183
	} catch(e) {
184
	    return work.err(e, "Failure to communicate with repository :(");
182 185
	}
183 186

  
187
	if (!work.is_ok)
188
	    return;
189

  
184 190
	if (!response.ok) {
185 191
	    return work.err(null,
186 192
			    `Repository sent HTTP code ${response.status} :(`);
187 193
	}
188 194

  
189
	if ("error_json" in response) {
190
	    return work.err(response.error_json,
191
			    "Repository's response is not valid JSON :(");
195
	try {
196
	    var json = await response.json();
197
	} catch(e) {
198
	    return work.err(e, "Repository's response is not valid JSON :(");
192 199
	}
193 200

  
201
	if (!work.is_ok)
202
	    return;
203

  
194 204
	const captype = item_type[0].toUpperCase() + item_type.substring(1);
195 205

  
196 206
	const $id =
197 207
	      `https://hydrilla.koszko.org/schemas/api_${item_type}_description-1.0.1.schema.json`;
198 208
	const schema = haketilo_schemas[$id];
199
	const result = haketilo_validator.validate(response.json, schema);
209
	const result = haketilo_validator.validate(json, schema);
200 210
	if (result.errors.length > 0) {
201 211
	    const reg = new RegExp(schema.allOf[2].properties.$schema.pattern);
202
	    if (response.json.$schema && !reg.test(response.json.$schema)) {
212
	    if (json.$schema && !reg.test(json.$schema)) {
203 213
		const msg = `${captype} ${item_id_string(id, ver)} was served using unsupported Hydrilla API version. You might need to update Haketilo.`;
204 214
		return work.err(result.errors, msg);
205 215
	    }
......
208 218
	    return work.err(result.errors, msg);
209 219
	}
210 220

  
211
	const scripts = item_type === "resource" && response.json.scripts;
212
	const files = response.json.source_copyright.concat(scripts || []);
221
	const scripts = item_type === "resource" && json.scripts;
222
	const files = json.source_copyright.concat(scripts || []);
213 223

  
214 224
	if (item_type === "mapping") {
215
	    for (const res_ref of Object.values(response.json.payloads || {}))
225
	    for (const res_ref of Object.values(json.payloads || {}))
216 226
		process_item(work, "resource", res_ref.identifier);
217 227
	} else {
218
	    for (const res_ref of (response.json.dependencies || []))
228
	    for (const res_ref of (json.dependencies || []))
219 229
		process_item(work, "resource", res_ref.identifier);
220 230
	}
221 231

  
......
234 244
	    const msg = "Error accessing Haketilo's internal database :(";
235 245
	    return work.err(e, msg);
236 246
	}
237
	if (!db_def || db_def.version < response.json.version)
238
	    work.result.push({def: response.json, db_def});
247
	if (!db_def || db_def.version < json.version)
248
	    work.result.push({def: json, db_def});
239 249

  
240 250
	if (--work.waiting === 0)
241 251
	    work.resolve_cb(work.result);
......
320 330
		return work.err(null, msg);
321 331
	    }
322 332

  
323
	    try {
324
		var text = await response.text();
325
		if (!work.is_ok)
326
		    return;
327
	    } catch(e) {
328
		const msg = "Repository's response is not valid text :(";
329
		return work.err(e, msg);
330
	    }
333
	    const text = await response.text();
334
	    if (!work.is_ok)
335
		return;
331 336

  
332 337
	    const digest = await compute_sha256(text);
333 338
	    if (!work.is_ok)
html/repo_query.js
49 49
#FROM html/install.js      IMPORT InstallView
50 50
#FROM common/jsonschema.js IMPORT haketilo_validator, haketilo_schemas
51 51

  
52
#FROM html/repo_query_cacher_client.js IMPORT indirect_fetch
53

  
52 54
const coll = new Intl.Collator();
53 55

  
54 56
function ResultEntry(repo_entry, mapping_ref) {
......
76 78

  
77 79
    this.repo_url_label.innerText = repo_url;
78 80

  
79
    const query_results = async () => {
80
	const msg = [
81
	    "repo_query",
82
	    `${repo_url}query?url=${encodeURIComponent(query_view.url)}`
83
	];
84
	const response = await browser.tabs.sendMessage(query_view.tab_id, msg);
81
    const encoded_queried_url = encodeURIComponent(query_view.url);
82
    const query_url = `${repo_url}query?url=${encoded_queried_url}`;
85 83

  
86
	if ("error" in response)
84
    const query_results = async () => {
85
	try {
86
	    var response = await indirect_fetch(query_view.tab_id, query_url);
87
	} catch(e) {
88
	    console.error("Haketilo:", e);
87 89
	    throw "Failure to communicate with repository :(";
90
	}
88 91

  
89 92
	if (!response.ok)
90 93
	    throw `Repository sent HTTP code ${response.status} :(`;
91
	if ("error_json" in response)
94

  
95
	try {
96
	    var json = await response.json();
97
	} catch(e) {
98
	    console.error("Haketilo:", e);
92 99
	    throw "Repository's response is not valid JSON :(";
100
	}
93 101

  
94 102
	const $id =
95 103
	      `https://hydrilla.koszko.org/schemas/api_query_result-1.0.1.schema.json`;
96 104
	const schema = haketilo_schemas[$id];
97
	const result = haketilo_validator.validate(response.json, schema);
105
	const result = haketilo_validator.validate(json, schema);
98 106
	if (result.errors.length > 0) {
99 107
	    console.error("Haketilo:", result.errors);
100 108

  
101 109
	    const reg = new RegExp(schema.properties.$schema.pattern);
102
	    if (response.json.$schema && !reg.test(response.json.$schema))
110
	    if (json.$schema && !reg.test(json.$schema))
103 111
		throw "Results were served using unsupported Hydrilla API version. You might need to update Haketilo.";
104 112

  
105 113
	    throw "Results were served using a nonconforming response format.";
106 114
	}
107 115

  
108
	return response.json.mappings;
116
	return json.mappings;
109 117
    }
110 118

  
111 119
    const populate_results = async () => {
html/repo_query_cacher_client.js
1
/**
2
 * This file is part of Haketilo.
3
 *
4
 * Function: Making requests to remote repositories through a response cache in
5
 *           operating in the content script of a browser tab.
6
 *
7
 * Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org>
8
 *
9
 * This program is free software: you can redistribute it and/or modify
10
 * it under the terms of the GNU General Public License as published by
11
 * the Free Software Foundation, either version 3 of the License, or
12
 * (at your option) any later version.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
17
 * GNU General Public License for more details.
18
 *
19
 * As additional permission under GNU GPL version 3 section 7, you
20
 * may distribute forms of that code without the copy of the GNU
21
 * GPL normally required by section 4, provided you include this
22
 * license notice and, in case of non-source distribution, a URL
23
 * through which recipients can access the Corresponding Source.
24
 * If you modify file(s) with this exception, you may extend this
25
 * exception to your version of the file(s), but you are not
26
 * obligated to do so. If you do not wish to do so, delete this
27
 * exception statement from your version.
28
 *
29
 * As a special exception to the GPL, any HTML file which merely
30
 * makes function calls to this code, and for that purpose
31
 * includes it by reference shall be deemed a separate work for
32
 * copyright law purposes. If you modify this code, you may extend
33
 * this exception to your version of the code, but you are not
34
 * obligated to do so. If you do not wish to do so, delete this
35
 * exception statement from your version.
36
 *
37
 * You should have received a copy of the GNU General Public License
38
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
39
 *
40
 * I, Wojtek Kosior, thereby promise not to sue for violation of this file's
41
 * license. Although I request that you do not make use of this code in a
42
 * proprietary program, I am not going to enforce this in court.
43
 */
44

  
45
#FROM common/browser.js IMPORT browser
46

  
47
/*
48
 * This is a complementary function to error_data_jsonifiable() in
49
 * common/misc.js.
50
 */
51
function error_from_jsonifiable_data(jsonifiable) {
52
    const arg_props = ["message", "fileName", "lineNumber"];
53
    return new window[jsonifiable.name](...arg_props.map(p => jsonifiable[p]));
54
}
55

  
56
/*
57
 * Make it possible to recover a Response object. This is a complementary
58
 * function to response_data_jsonifiable() in background/CORS_bypass_server.js.
59
 */
60
function response_from_jsonifiable_data(jsonifiable) {
61
    const body = jsonifiable.body, body_len = body.length / 2;
62
    const body_buf = new Uint8Array(body_len);
63

  
64
    for (let i = 0; i < body_len; i++)
65
	body_buf[i] = parseInt(`0x${body.substring(i * 2, i * 2 + 2)}`);
66

  
67
    const init = {
68
	status:     jsonifiable.status,
69
	statusText: jsonifiable.statusText,
70
	headers:    new Headers(jsonifiable.headers)
71
    };
72

  
73
    return new Response(body_buf, init);
74
}
75

  
76
async function indirect_fetch(tab_id, url) {
77
    const msg = ["repo_query", url];
78
    const jsonifiable = await browser.tabs.sendMessage(tab_id, msg);
79

  
80
    if ("error" in jsonifiable)
81
	throw error_from_jsonifiable_data(jsonifiable);
82

  
83
    return response_from_jsonifiable_data(jsonifiable);
84
}
85
#EXPORT indirect_fetch
test/haketilo_test/unit/test_CORS_bypass_server.py
24 24
from ..script_loader import load_script
25 25
from ..world_wide_library import some_data
26 26

  
27
urls = {
28
    'resource': 'https://anotherdoma.in/resource/blocked/by/CORS.json',
29
    'nonexistent': 'https://nxdoma.in/resource.json',
30
    'invalid': 'w3csucks://invalid.url/'
27
datas = {
28
    'resource':       'https://anotherdoma.in/resource/blocked/by/CORS.json',
29
    'nonexistent':    'https://nxdoma.in/resource.json',
30
    'invalid':        'w3csucks://invalid.url/',
31
    'redirected_ok':  'https://site.with.scripts.block.ed',
32
    'redirected_err': 'https://site.with.scripts.block.ed'
31 33
}
32 34

  
35
for name, url in [*datas.items()]:
36
    datas[name] = {'url': url}
37

  
38
datas['redirected_ok']['init']  = {'redirect': 'follow'}
39
datas['redirected_err']['init'] = {'redirect': 'error'}
40

  
33 41
content_script = '''\
34
const urls = %s;
35

  
36
function fetch_data(url) {
37
    return {
38
        url,
39
        to_get: ["ok", "status"],
40
        to_call: ["text", "json"]
41
    };
42
}
42
const datas = %s;
43 43

  
44 44
async function fetch_resources() {
45 45
    const results = {};
46 46
    const promises = [];
47
    for (const [name, url] of Object.entries(urls)) {
48
        const sending = browser.runtime.sendMessage(["CORS_bypass",
49
                                                     fetch_data(url)]);
47
    for (const [name, data] of Object.entries(datas)) {
48
        const sending = browser.runtime.sendMessage(["CORS_bypass", data]);
50 49
        promises.push(sending.then(response => results[name] = response));
51 50
    }
52 51

  
......
58 57
fetch_resources();
59 58
'''
60 59

  
61
content_script = content_script % json.dumps(urls);
60
content_script = content_script % json.dumps(datas);
62 61

  
63 62
@pytest.mark.ext_data({
64 63
    'content_script': content_script,
......
77 76
        '''
78 77
        const result = {};
79 78
        let promises = [];
80
        for (const [name, url] of Object.entries(arguments[0])) {
79
        for (const [name, data] of Object.entries(arguments[0])) {
81 80
            const [ok_cb, err_cb] =
82 81
                ["ok", "err"].map(status => () => result[name] = status);
83
            promises.push(fetch(url).then(ok_cb, err_cb));
82
            promises.push(fetch(data.url).then(ok_cb, err_cb));
84 83
        }
85 84
        // Make the promises non-failing.
86 85
        promises = promises.map(p => new Promise(cb => p.then(cb, cb)));
87 86
        returnval(Promise.all(promises).then(() => result));
88 87
        ''',
89
        {**urls, 'sameorigin': './nonexistent_resource'})
88
        {**datas, 'sameorigin': './nonexistent_resource'})
90 89

  
91
    assert results == dict([*[(k, 'err') for k in urls.keys()],
90
    assert results == dict([*[(k, 'err') for k in datas.keys()],
92 91
                            ('sameorigin', 'ok')])
93 92

  
94 93
    done = lambda d: d.execute_script('return window.haketilo_fetch_results;')
95 94
    results = WebDriverWait(driver, 10).until(done)
96 95

  
97 96
    assert set(results['invalid'].keys()) == {'error'}
97
    assert results['invalid']['error']['fileName'].endswith('background.js')
98
    assert type(results['invalid']['error']['lineNumber']) is int
99
    assert type(results['invalid']['error']['message']) is str
100
    assert results['invalid']['error']['name'] == 'TypeError'
98 101

  
99
    assert set(results['nonexistent'].keys()) == \
100
        {'ok', 'status', 'text', 'error_json'}
101
    assert results['nonexistent']['ok'] == False
102 102
    assert results['nonexistent']['status'] == 404
103
    assert results['nonexistent']['text'] == 'Handler for this URL not found.'
103
    assert results['nonexistent']['statusText'] == 'Not Found'
104
    assert any([name.lower() == 'content-length'
105
                for name, value in results['nonexistent']['headers']])
106
    assert bytes.fromhex(results['nonexistent']['body']) == \
107
        b'Handler for this URL not found.'
104 108

  
105
    assert set(results['resource'].keys()) == {'ok', 'status', 'text', 'json'}
106
    assert results['resource']['ok'] == True
107 109
    assert results['resource']['status'] == 200
108
    assert results['resource']['text'] == some_data
109
    assert results['resource']['json'] == json.loads(some_data)
110
    assert results['resource']['statusText'] == 'OK'
111
    assert any([name.lower() == 'content-length'
112
                for name, value in results['resource']['headers']])
113
    assert bytes.fromhex(results['resource']['body']) == b'{"some": "data"}'
114

  
115
    assert results['redirected_ok']['status'] == 200
116
    assert results['redirected_err']['error']['name'] == 'TypeError'
test/haketilo_test/unit/test_install.py
26 26
from .utils import *
27 27

  
28 28
def setup_view(driver, execute_in_page):
29
    mock_cacher(execute_in_page)
29
    execute_in_page(mock_cacher_code)
30 30

  
31 31
    execute_in_page(load_script('html/install.js'))
32 32
    container_ids, containers_objects = execute_in_page(
......
203 203
    'indexeddb_error_file_uses',
204 204
    'failure_to_communicate_fetch',
205 205
    'HTTP_code_file',
206
    'not_valid_text',
207 206
    'sha256_mismatch',
208 207
    'indexeddb_error_write'
209 208
])
......
243 242
    if message == 'fetching_data':
244 243
        execute_in_page(
245 244
            '''
246
            browser.tabs.sendMessage = () => new Promise(cb => {});
245
            window.mock_cacher_fetch = () => new Promise(cb => {});
247 246
            install_view.show(...arguments);
248 247
            ''',
249 248
            'https://hydril.la/', 'mapping', 'mapping-a')
......
253 252
    elif message == 'failure_to_communicate_sendmessage':
254 253
        execute_in_page(
255 254
            '''
256
            browser.tabs.sendMessage = () => Promise.resolve({error: "sth"});
255
            window.mock_cacher_fetch =
256
                () => {throw new Error("Something happened :o")};
257 257
            install_view.show(...arguments);
258 258
            ''',
259 259
            'https://hydril.la/', 'mapping', 'mapping-a')
......
262 262
    elif message == 'HTTP_code_item':
263 263
        execute_in_page(
264 264
            '''
265
            const response = {ok: false, status: 404};
266
            browser.tabs.sendMessage = () => Promise.resolve(response);
265
            const response = new Response("", {status: 404});
266
            window.mock_cacher_fetch = () => Promise.resolve(response);
267 267
            install_view.show(...arguments);
268 268
            ''',
269 269
            'https://hydril.la/', 'mapping', 'mapping-a')
......
272 272
    elif message == 'invalid_JSON':
273 273
        execute_in_page(
274 274
            '''
275
            const response = {ok: true, status: 200, error_json: "sth"};
276
            browser.tabs.sendMessage = () => Promise.resolve(response);
275
            const response = new Response("sth", {status: 200});
276
            window.mock_cacher_fetch = () => Promise.resolve(response);
277 277
            install_view.show(...arguments);
278 278
            ''',
279 279
            'https://hydril.la/', 'mapping', 'mapping-a')
......
282 282
    elif message == 'newer_API_version':
283 283
        execute_in_page(
284 284
            '''
285
            const old_sendMessage = browser.tabs.sendMessage;
286
            browser.tabs.sendMessage = async function(...args) {
287
                const response = await old_sendMessage(...args);
288
                response.json.$schema = "https://hydrilla.koszko.org/schemas/api_mapping_description-255.1.schema.json";
289
                return response;
290
            }
285
            const newer_schema_url =
286
                "https://hydrilla.koszko.org/schemas/api_mapping_description-255.1.schema.json";
287
            const mocked_json_data = JSON.stringify({$schema: newer_schema_url});
288
            const response = new Response(mocked_json_data, {status: 200});
289
            window.mock_cacher_fetch = () => Promise.resolve(response);
291 290
            install_view.show(...arguments);
292 291
            ''',
293 292
            'https://hydril.la/', 'mapping', 'mapping-a', [2022, 5, 10])
......
297 296
    elif message == 'invalid_response_format':
298 297
        execute_in_page(
299 298
            '''
300
            const old_sendMessage = browser.tabs.sendMessage;
301
            browser.tabs.sendMessage = async function(...args) {
302
                const response = await old_sendMessage(...args);
303
                /* identifier is not a string as it should be. */
304
                response.json.identifier = 1234567;
305
                return response;
299
            window.mock_cacher_fetch = async function(...args) {
300
                const response = await fetch(...args);
301
                const json = await response.json();
302

  
303
                /* identifier is no longer a string as it should be. */
304
                json.identifier = 1234567;
305

  
306
                return new Response(JSON.stringify(json), {
307
                    status:     response.status,
308
                    statusText: response.statusText,
309
                    headers:    [...response.headers.entries()]
310
                });
306 311
            }
307 312
            install_view.show(...arguments);
308 313
            ''',
......
352 357
    elif message == 'failure_to_communicate_fetch':
353 358
        execute_in_page(
354 359
            '''
355
            fetch = () => {throw "some error";};
360
            fetch = () => {throw new Error("some error");};
356 361
            returnval(install_view.show(...arguments));
357 362
            ''',
358 363
            'https://hydril.la/', 'mapping', 'mapping-b')
......
372 377
        execute_in_page('returnval(install_view.install_but);').click()
373 378

  
374 379
        assert_dlg(['conf_buts'], 'Repository sent HTTP code 400 :(')
375
    elif message == 'not_valid_text':
376
        execute_in_page(
377
            '''
378
            const err = () => {throw "some error";};
379
            fetch = () => Promise.resolve({ok: true, status: 200, text: err});
380
            returnval(install_view.show(...arguments));
381
            ''',
382
            'https://hydril.la/', 'mapping', 'mapping-b')
383

  
384
        execute_in_page('returnval(install_view.install_but);').click()
385

  
386
        assert_dlg(['conf_buts'], "Repository's response is not valid text :(")
387 380
    elif message == 'sha256_mismatch':
388 381
        execute_in_page(
389 382
            '''
test/haketilo_test/unit/test_popup.py
81 81
tab_mock_js = '''
82 82
;
83 83
const mocked_page_info = (%s)[/#mock_page_info-(.*)$/.exec(document.URL)[1]];
84
const old_sendMessage = browser.tabs.sendMessage;
84 85
browser.tabs.sendMessage = async function(tab_id, msg) {
85 86
    const this_tab_id = (await browser.tabs.getCurrent()).id;
86 87
    if (tab_id !== this_tab_id)
87 88
        throw `not current tab id (${tab_id} instead of ${this_tab_id})`;
88 89

  
89
    if (msg[0] === "page_info") {
90
    if (msg[0] === "page_info")
90 91
        return mocked_page_info;
91
    } else if (msg[0] === "repo_query") {
92
        const response = await fetch(msg[1]);
93
        if (!response)
94
            return {error: "Something happened :o"};
95

  
96
        const result = {ok: response.ok, status: response.status};
97
        try {
98
            result.json = await response.json();
99
        } catch(e) {
100
            result.error_json = "" + e;
101
        }
102
        return result;
103
    } else {
92
    else if (msg[0] === "repo_query")
93
        return old_sendMessage(tab_id, msg);
94
    else
104 95
        throw `bad sendMessage message type: '${msg[0]}'`;
105
    }
106 96
}
107 97

  
108 98
const old_tabs_query = browser.tabs.query;
......
113 103
}
114 104
''' % json.dumps(mocked_page_infos)
115 105

  
106
tab_mock_js = mock_cacher_code + tab_mock_js
107

  
116 108
popup_ext_data = {
117 109
    'background_script': broker_js,
118 110
    'extra_html': ExtraHTML(
test/haketilo_test/unit/test_repo_query.py
29 29
queried_url = 'https://example_a.com/something'
30 30

  
31 31
def setup_view(execute_in_page, repo_urls, tab={'id': 0}):
32
    mock_cacher(execute_in_page)
32
    execute_in_page(mock_cacher_code)
33 33

  
34 34
    execute_in_page(load_script('html/repo_query.js'))
35 35
    execute_in_page(
......
185 185
    elif message == 'failure_to_communicate':
186 186
        setup_view(execute_in_page, repo_urls)
187 187
        execute_in_page(
188
            'browser.tabs.sendMessage = () => Promise.resolve({error: "sth"});'
189
        )
188
            '''
189
            window.mock_cacher_fetch =
190
                () => {throw new Error("Something happened :o")};
191
            ''')
190 192
        show_and_wait_for_repo_entry()
191 193

  
192 194
        elem = execute_in_page('returnval(view.repo_entries[0].info_div);')
......
196 198
        setup_view(execute_in_page, repo_urls)
197 199
        execute_in_page(
198 200
            '''
199
            const response = {ok: false, status: 405};
200
            browser.tabs.sendMessage = () => Promise.resolve(response);
201
            const response = new Response("", {status: 405});
202
            window.mock_cacher_fetch = () => Promise.resolve(response);
201 203
            ''')
202 204
        show_and_wait_for_repo_entry()
203 205

  
......
208 210
        setup_view(execute_in_page, repo_urls)
209 211
        execute_in_page(
210 212
            '''
211
            const response = {ok: true, status: 200, error_json: "sth"};
212
            browser.tabs.sendMessage = () => Promise.resolve(response);
213
            const response = new Response("sth", {status: 200});
214
            window.mock_cacher_fetch = () => Promise.resolve(response);
213 215
            ''')
214 216
        show_and_wait_for_repo_entry()
215 217

  
......
220 222
        setup_view(execute_in_page, repo_urls)
221 223
        execute_in_page(
222 224
            '''
223
            const response = {
224
                ok: true,
225
                status: 200,
226
                json: {$schema: "https://hydrilla.koszko.org/schemas/api_query_result-255.2.1.schema.json"}
227
            };
228
            browser.tabs.sendMessage = () => Promise.resolve(response);
225
            const newer_schema_url =
226
                "https://hydrilla.koszko.org/schemas/api_query_result-255.2.1.schema.json";
227
            const mocked_json_data = JSON.stringify({$schema: newer_schema_url});
228
            const response = new Response(mocked_json_data, {status: 200});
229
            window.mock_cacher_fetch = () => Promise.resolve(response);
229 230
            ''')
230 231
        show_and_wait_for_repo_entry()
231 232

  
......
236 237
        setup_view(execute_in_page, repo_urls)
237 238
        execute_in_page(
238 239
            '''
239
            const response = {
240
                ok: true,
241
                status: 200,
242
                /* $schema is not a string as it should be. */
243
                json: {$schema: null}
244
            };
245
            browser.tabs.sendMessage = () => Promise.resolve(response);
240
            window.mock_cacher_fetch = async function(...args) {
241
                const response = await fetch(...args);
242
                const json = await response.json();
243

  
244
                /* $schema is no longer a string as it should be. */
245
                json.$schema = null;
246

  
247
                return new Response(JSON.stringify(json), {
248
                    status:     response.status,
249
                    statusText: response.statusText,
250
                    headers:    [...response.headers.entries()]
251
                });
252
            }
246 253
            ''')
247 254
        show_and_wait_for_repo_entry()
248 255

  
......
252 259
    elif message == 'querying_repo':
253 260
        setup_view(execute_in_page, repo_urls)
254 261
        execute_in_page(
255
            'browser.tabs.sendMessage = () => new Promise(() => {});'
262
            'window.mock_cacher_fetch = () => new Promise(cb => {});'
256 263
        )
257 264
        show_and_wait_for_repo_entry()
258 265

  
......
262 269
        setup_view(execute_in_page, repo_urls)
263 270
        execute_in_page(
264 271
            '''
265
            const response = {
266
                ok: true,
267
                status: 200,
268
                json: {
269
                    $schema: "https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json",
270
                    mappings: []
271
                }
272
            };
273
            browser.tabs.sendMessage = () => Promise.resolve(response);
272
            const schema_url =
273
                "https://hydrilla.koszko.org/schemas/api_query_result-1.schema.json";
274
            const mocked_json_data =
275
                JSON.stringify({$schema: schema_url, mappings: []});
276
            const response = new Response(mocked_json_data, {status: 200});
277
            window.mock_cacher_fetch = () => Promise.resolve(response);
274 278
            ''')
275 279
        show_and_wait_for_repo_entry()
276 280

  
test/haketilo_test/unit/test_repo_query_cacher.py
85 85
    'background_script': lambda: bypass_js() + ';' + tab_id_responder
86 86
})
87 87
@pytest.mark.usefixtures('webextension')
88
def test_repo_query_cacher_normal_use(driver, execute_in_page):
88
def test_repo_query_cacher_normal_use(driver):
89 89
    """
90 90
    Test if HTTP requests made through our cacher return correct results.
91 91
    """
92 92
    tab_id = run_content_script_in_new_window(driver, 'https://gotmyowndoma.in')
93 93

  
94 94
    result = fetch_through_cache(driver, tab_id, 'https://counterdoma.in/')
95
    assert set(result.keys()) == {'ok', 'status', 'json'}
96
    counter_initial = result['json']['counter']
95
    assert set(result.keys()) == {'status', 'statusText', 'headers', 'body'}
96
    counter_initial = json.loads(bytes.fromhex(result['body']))['counter']
97 97
    assert type(counter_initial) is int
98 98

  
99 99
    for i in range(2):
100 100
        result = fetch_through_cache(driver, tab_id, 'https://counterdoma.in/')
101
        assert result['json']['counter'] == counter_initial
101
        assert json.loads(bytes.fromhex(result['body'])) \
102
            == {'counter': counter_initial}
102 103

  
103 104
    tab_id = run_content_script_in_new_window(driver, 'https://gotmyowndoma.in')
104 105
    result = fetch_through_cache(driver, tab_id, 'https://counterdoma.in/')
105
    assert result['json']['counter'] == counter_initial + 1
106
    assert json.loads(bytes.fromhex(result['body'])) \
107
        == {'counter': counter_initial + 1}
106 108

  
107 109
    for i in range(2):
108 110
        result = fetch_through_cache(driver, tab_id, 'https://nxdoma.in/')
109
        assert set(result.keys()) == {'ok', 'status', 'error_json'}
110
        assert result['ok'] == False
111 111
        assert result['status'] == 404
112 112

  
113 113
    for i in range(2):
114 114
        result = fetch_through_cache(driver, tab_id, 'bad://url')
115 115
        assert set(result.keys()) == {'error'}
116
        assert result['error']['name'] == 'TypeError'
116 117

  
117 118
@pytest.mark.ext_data({
118 119
    'content_script': content_script,
......
128 129

  
129 130
    result = fetch_through_cache(driver, tab_id, 'https://counterdoma.in/')
130 131
    assert set(result.keys()) == {'error'}
132
    assert set(result['error'].keys()) == \
133
        {'name', 'message', 'fileName', 'lineNumber'}
134
    assert result['error']['message'] == \
135
        "Couldn't communicate with background script."
test/haketilo_test/unit/utils.py
246 246
        'Object.keys(broadcast).forEach(k => broadcast[k] = () => {});'
247 247
    )
248 248

  
249
def mock_cacher(execute_in_page):
250
    """
251
    Some parts of code depend on content/repo_query_cacher.js and
252
    background/CORS_bypass_server.js running in their appropriate contexts. This
253
    function modifies the relevant browser.runtime.sendMessage function to
254
    perform fetch(), bypassing the cacher.
255
    """
256
    execute_in_page(
257
        '''{
258
        const old_sendMessage = browser.tabs.sendMessage, old_fetch = fetch;
259
        async function new_sendMessage(tab_id, msg) {
260
            if (msg[0] !== "repo_query")
261
                return old_sendMessage(tab_id, msg);
249
"""
250
Some parts of code depend on content/repo_query_cacher.js and
251
background/CORS_bypass_server.js running in their appropriate contexts. This
252
snippet modifies the relevant browser.runtime.sendMessage function to perform
253
fetch(), bypassing the cacher.
254
"""
255
mock_cacher_code = '''{
256
const uint8_to_hex =
257
    array => [...array].map(b => ("0" + b.toString(16)).slice(-2)).join("");
262 258

  
263
            /* Use snapshotted fetch(), allow other test code to override it. */
264
            const response = await old_fetch(msg[1]);
265
            if (!response)
266
                return {error: "Something happened :o"};
259
const old_sendMessage = browser.tabs.sendMessage;
260
window.mock_cacher_fetch = fetch;
261
browser.tabs.sendMessage = async function(tab_id, msg) {
262
    if (msg[0] !== "repo_query")
263
        return old_sendMessage(tab_id, msg);
267 264

  
268
            const result = {ok: response.ok, status: response.status};
269
            try {
270
                result.json = await response.json();
271
            } catch(e) {
272
                result.error_json = "" + e;
273
            }
274
            return result;
265
    /*
266
     * Use snapshotted fetch() under the name window.mock_cacher_fetch,
267
     * allow other test code to override it.
268
     */
269
    try {
270
        const response = await window.mock_cacher_fetch(msg[1]);
271
        const buf = await response.arrayBuffer();
272
        return {
273
            status:     response.status,
274
            statusText: response.statusText,
275
            headers:    [...response.headers.entries()],
276
            body:       uint8_to_hex(new Uint8Array(buf))
275 277
        }
276

  
277
        browser.tabs.sendMessage = new_sendMessage;
278
        }''')
278
    } catch(e) {
279
        return {error: {name: e.name, message: e.message}};
280
    }
281
}
282
}'''
279 283

  
280 284
"""
281 285
Convenience snippet of code to retrieve a copy of given object with only those

Also available in: Unified diff