diff options
Diffstat (limited to 'test')
-rw-r--r-- | test/haketilo_test/unit/test_CORS_bypass_server.py | 65 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_content.py | 17 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_haketilo_apis.py | 129 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_install.py | 170 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_policy_deciding.py | 61 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_policy_enforcing.py | 10 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_popup.py | 22 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_repo_query.py | 64 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_repo_query_cacher.py | 19 | ||||
-rw-r--r-- | test/haketilo_test/unit/test_webrequest.py | 4 | ||||
-rw-r--r-- | test/haketilo_test/unit/utils.py | 73 | ||||
-rw-r--r-- | test/haketilo_test/world_wide_library.py | 52 |
12 files changed, 456 insertions, 230 deletions
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_content.py b/test/haketilo_test/unit/test_content.py index 8220160..98ea930 100644 --- a/test/haketilo_test/unit/test_content.py +++ b/test/haketilo_test/unit/test_content.py @@ -88,6 +88,7 @@ content_script = \ } repo_query_cacher.start = () => data_set("cacher_started", true); + haketilo_apis.start = () => data_set("apis_started", true); enforce_blocking = policy => data_set("enforcing", policy); @@ -118,7 +119,7 @@ content_script = \ @pytest.mark.ext_data({'content_script': content_script}) @pytest.mark.usefixtures('webextension') -@pytest.mark.parametrize('target1', ['dynamic_before'])#, 'dynamic_after']) +@pytest.mark.parametrize('target1', ['dynamic_before', 'dynamic_after']) @pytest.mark.parametrize('target2', [ 'scripts_blocked', 'payload_error', @@ -144,6 +145,7 @@ def test_content_unprivileged_page(driver, execute_in_page, target1, target2): assert data['bad_request_returned'] == False assert data['cacher_started'] == True + assert data.get('apis_started', False) == (target2 == 'payload_ok') for obj in (data['good_request_result'], data['enforcing']): assert obj['allow'] == False @@ -162,9 +164,13 @@ def test_content_unprivileged_page(driver, execute_in_page, target1, target2): def vars_made_by_payload(driver): vars_values = driver.execute_script( - 'return [1, 2].map(n => window[`hak_injected_${n}`]);' - ) - if vars_values != [None, None]: + ''' + return [ + ...[1, 2].map(n => window[`hak_injected_${n}`]), + window.haketilo_version + ]; + ''') + if vars_values != [None, None, None]: return vars_values if target2 == 'payload_error': @@ -174,7 +180,8 @@ def test_content_unprivileged_page(driver, execute_in_page, target1, target2): } elif target2 == 'payload_ok': vars_values = WebDriverWait(driver, 10).until(vars_made_by_payload) - assert vars_values == [1, 2] + assert vars_values[:2] == [1, 2] + assert type(vars_values[2]) == str @pytest.mark.ext_data({'content_script': content_script}) @pytest.mark.usefixtures('webextension') diff --git a/test/haketilo_test/unit/test_haketilo_apis.py b/test/haketilo_test/unit/test_haketilo_apis.py new file mode 100644 index 0000000..af7906d --- /dev/null +++ b/test/haketilo_test/unit/test_haketilo_apis.py @@ -0,0 +1,129 @@ +# SPDX-License-Identifier: CC0-1.0 + +""" +Haketilo unit tests - exposing some special functionalities to injected scripts +""" + +# This file is part of Haketilo +# +# Copyright (C) 2022 Wojtek Kosior <koszko@koszko.org> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the CC0 1.0 Universal License as published by +# the Creative Commons Corporation. +# +# 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 +# CC0 1.0 Universal License for more details. + +import pytest +import json +from selenium.webdriver.support.ui import WebDriverWait + +from ..script_loader import load_script +from ..world_wide_library import some_data + +def content_script(): + return load_script('content/haketilo_apis.js') + ';\nstart();' + +def background_script(): + return load_script('background/CORS_bypass_server.js') + ';\nstart();' + +resource_url = 'https://anotherdoma.in/resource/blocked/by/CORS.json' + +@pytest.mark.ext_data({ + 'content_script': content_script, + 'background_script': background_script +}) +@pytest.mark.usefixtures('webextension') +def test_haketilo_apis_CORS_bypass(driver): + """ + Verify injected scripts will be able to bypass CORS with the help of + Haketilo API. + """ + driver.get('https://gotmyowndoma.in/') + + # First, verify that it is impossible to normally fetch the resource. + with pytest.raises(Exception, match='NetworkError'): + driver.execute_script('return fetch(arguments[0]);', resource_url) + + # First, verify that it is possible to fetch the resource using API. + response = driver.execute_script( + ''' + const fetch_arg = { + url: arguments[0], + init: {}, + verify_that_nonstandard_properties_are_ignored: ":)" + }; + + const detail = { + data: JSON.stringify(fetch_arg), + id: "abcdef", + nonstandard_properties_verify_that_ignored_are: ":o" + }; + + let cb, done = new Promise(_cb => cb = _cb); + window.addEventListener("haketilo_CORS_bypass-abcdef", + e => cb(JSON.parse(e.detail))); + window.dispatchEvent(new CustomEvent("haketilo_CORS_bypass", {detail})); + + return done; + ''', + resource_url) + + assert response['body'] == some_data.encode().hex() + assert response['status'] == 200 + assert type(response['headers']) is list + +@pytest.mark.ext_data({ + 'content_script': content_script, + 'background_script': background_script +}) +@pytest.mark.usefixtures('webextension') +@pytest.mark.parametrize('error', [ + 'bad url', + 'no_url', + 'non_string_url', + 'non_object_init', + 'non_object_detail', + 'non_string_id', + 'non_string_data' +]) +def test_haketilo_apis_CORS_bypass_errors(driver, error): + """ + Verify errors are returned properly by CORS_bypass API. + """ + data = { + 'bad_url': {'url': 'muahahahaha', 'init': {}}, + 'no_url': {'init': {}}, + 'non_string_url': {'url': {}, 'init': {}}, + 'non_object_init': {'url': {}, 'init': ":d"}, + }.get(error, {'url': resource_url, 'init': {}}) + + detail = { + 'non_object_detail': '!!!', + 'non_string_id': {'data': json.dumps(data), 'id': None}, + 'non_string_data': {'data': data, 'id': 'abcdef'} + }.get(error, {'data': json.dumps(data), 'id': 'abcdef'}) + + driver.get('https://gotmyowndoma.in/') + + result = driver.execute_script( + ''' + let cb, done = new Promise(_cb => cb = _cb); + window.addEventListener("haketilo_CORS_bypass-abcdef", + e => cb(JSON.parse(e.detail))); + window.dispatchEvent(new CustomEvent("haketilo_CORS_bypass", + {detail: arguments[0]})); + setTimeout(() => cb("timeout"), 5000); + + return done; + ''', + detail) + + if error in {'bad_url', 'no_url', 'non_string_url', 'non_object_init'}: + assert result['error']['name'] == 'TypeError' + + if error in {'non_object_detail', 'non_string_id', 'non_string_data'}: + assert result == 'timeout' diff --git a/test/haketilo_test/unit/test_install.py b/test/haketilo_test/unit/test_install.py index 1e2063c..b1321ff 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( @@ -57,8 +57,38 @@ install_ext_data = { @pytest.mark.ext_data(install_ext_data) @pytest.mark.usefixtures('webextension') -@pytest.mark.parametrize('complex_variant', [False, True]) -def test_install_normal_usage(driver, execute_in_page, complex_variant): +@pytest.mark.parametrize('variant', [{ + # The resource/mapping others depend on. + 'root_resource_id': f'resource-abcd-defg-ghij', + 'root_mapping_id': f'mapping-abcd-defg-ghij', + # Those ids are used to check the alphabetical ordering. + 'item_ids': [f'resource-{letters}' for letters in ( + 'a', 'abcd', 'abcd-defg-ghij', 'b', 'c', + 'd', 'defg', 'e', 'f', + 'g', 'ghij', 'h', 'i', 'j' + )], + 'files_count': 9 +}, { + 'root_resource_id': 'resource-a', + 'root_mapping_id': 'mapping-a', + 'item_ids': ['resource-a'], + 'files_count': 0 +}, { + 'root_resource_id': 'resource-a-w-required-mapping-v1', + 'root_mapping_id': 'mapping-a-w-required-mapping-v1', + 'item_ids': ['resource-a-w-required-mapping-v1'], + 'files_count': 1 +}, { + 'root_resource_id': 'resource-a-w-required-mapping-v2', + 'root_mapping_id': 'mapping-a-w-required-mapping-v2', + 'item_ids': [ + 'mapping-a', + 'resource-a', + 'resource-a-w-required-mapping-v2' + ], + 'files_count': 1 +}]) +def test_install_normal_usage(driver, execute_in_page, variant): """ Test of the normal package installation procedure with one mapping and, depending on parameter, one or many resources. @@ -67,41 +97,27 @@ def test_install_normal_usage(driver, execute_in_page, complex_variant): assert execute_in_page('returnval(shw());') == [[], False] - if complex_variant: - # The resource/mapping others depend on. - root_id = 'abcd-defg-ghij' - root_resource_id = f'resource-{root_id}' - root_mapping_id = f'mapping-{root_id}' - # Those ids are used to check the alphabetical ordering. - resource_ids = [f'resource-{letters}' for letters in ( - 'a', 'abcd', root_id, 'b', 'c', - 'd', 'defg', 'e', 'f', - 'g', 'ghij', 'h', 'i', 'j' - )] - files_count = 9 - else: - root_resource_id = f'resource-a' - root_mapping_id = f'mapping-a' - resource_ids = [root_resource_id] - files_count = 0 - # Preview the installation of a resource, show resource's details, close # the details and cancel installation. execute_in_page('returnval(install_view.show(...arguments));', - 'https://hydril.la/', 'resource', root_resource_id) + 'https://hydril.la/', 'resource', + variant['root_resource_id']) assert execute_in_page('returnval(shw());') == [['show'], True] - assert f'{root_resource_id}-2021.11.11-1'\ + assert f'{variant["root_resource_id"]}-2021.11.11-1'\ in containers['install_preview'].text assert_container_displayed('install_preview') entries = execute_in_page('returnval(ets().map(e => e.main_li.innerText));') - assert len(entries) == len(resource_ids) + assert len(entries) == len(variant['item_ids']) + resource_idx = variant['item_ids'].index(variant['root_resource_id']) # Verify alphabetical ordering. - assert all([id in text for id, text in zip(resource_ids, entries)]) + assert all([id in text for id, text in + zip(variant['item_ids'], entries)]) - assert not execute_in_page('returnval(ets()[0].old_ver);').is_displayed() - execute_in_page('returnval(ets()[0].details_but);').click() + assert not execute_in_page(f'returnval(ets()[{resource_idx}].old_ver);')\ + .is_displayed() + execute_in_page(f'returnval(ets()[{resource_idx}].details_but);').click() assert 'resource-a' in containers['resource_preview_container'].text assert_container_displayed('resource_preview_container') @@ -116,20 +132,24 @@ def test_install_normal_usage(driver, execute_in_page, complex_variant): # details, close the details and commit the installation. execute_in_page('returnval(install_view.show(...arguments));', 'https://hydril.la/', 'mapping', - root_mapping_id, [2022, 5, 10]) + variant['root_mapping_id'], [2022, 5, 10]) assert execute_in_page('returnval(shw(2));') == [['show'], True] assert_container_displayed('install_preview') entries = execute_in_page('returnval(ets().map(e => e.main_li.innerText));') - assert len(entries) == len(resource_ids) + 1 - assert f'{root_mapping_id}-2022.5.10' in entries[0] + assert len(entries) == len(variant['item_ids']) + 1 + + all_item_ids = sorted([*variant['item_ids'], variant['root_mapping_id']]) + mapping_idx = all_item_ids.index(variant["root_mapping_id"]) # Verify alphabetical ordering. - assert all([id in text for id, text in zip(resource_ids, entries[1:])]) + assert all([id in text for id, text in zip(all_item_ids, entries)]) - assert not execute_in_page('returnval(ets()[0].old_ver);').is_displayed() - execute_in_page('returnval(ets()[0].details_but);').click() - assert root_mapping_id in containers['mapping_preview_container'].text + assert not execute_in_page(f'returnval(ets()[{mapping_idx}].old_ver);')\ + .is_displayed() + execute_in_page(f'returnval(ets()[{mapping_idx}].details_but);').click() + assert variant['root_mapping_id'] in \ + containers['mapping_preview_container'].text assert_container_displayed('mapping_preview_container') execute_in_page('returnval(install_view.mapping_back_but);').click() @@ -145,16 +165,20 @@ def test_install_normal_usage(driver, execute_in_page, complex_variant): # Verify the install db_contents = get_db_contents(execute_in_page) - for item_type, ids in \ - [('mapping', {root_mapping_id}), ('resource', set(resource_ids))]: + all_map_ids = {id for id in all_item_ids if id.startswith('mapping')} + all_res_ids = {id for id in all_item_ids if id.startswith('resource')} + for item_type, ids in [ + ('mapping', all_map_ids), + ('resource', all_res_ids) + ]: assert set([it['identifier'] for it in db_contents[item_type]]) == ids - assert all([len(db_contents[store]) == files_count + assert all([len(db_contents[store]) == variant['files_count'] for store in ('file', 'file_uses')]) # Update the installed mapping to a newer version. execute_in_page('returnval(install_view.show(...arguments));', - 'https://hydril.la/', 'mapping', root_mapping_id) + 'https://hydril.la/', 'mapping', variant['root_mapping_id']) assert execute_in_page('returnval(shw(4));') == [['show'], True] # resources are already in the newest versions, hence they should not appear # in the install preview list. @@ -171,12 +195,19 @@ def test_install_normal_usage(driver, execute_in_page, complex_variant): # Verify the newer version install. old_db_contents, db_contents = db_contents, get_db_contents(execute_in_page) - old_db_contents['mapping'][0]['version'][-1] += 1 - assert db_contents['mapping'] == old_db_contents['mapping'] + + old_root_mapping = [m for m in old_db_contents['mapping'] + if m['identifier'] == variant['root_mapping_id']][0] + old_root_mapping['version'][-1] += 1 + + new_root_mapping = [m for m in db_contents['mapping'] + if m['identifier'] == variant['root_mapping_id']][0] + + assert old_root_mapping == new_root_mapping # All items are up to date - verify dialog is instead shown in this case. execute_in_page('install_view.show(...arguments);', - 'https://hydril.la/', 'mapping', root_mapping_id) + 'https://hydril.la/', 'mapping', variant['root_mapping_id']) fetched = lambda d: 'Fetching ' not in containers['dialog_container'].text WebDriverWait(driver, 10).until(fetched) @@ -203,7 +234,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 +273,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 +283,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 +293,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 +303,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 +313,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 +327,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 +388,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 +408,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_policy_deciding.py b/test/haketilo_test/unit/test_policy_deciding.py index 75b35ac..1be488f 100644 --- a/test/haketilo_test/unit/test_policy_deciding.py +++ b/test/haketilo_test/unit/test_policy_deciding.py @@ -23,19 +23,36 @@ import pytest from ..script_loader import load_script -csp_re = re.compile(r'^\S+\s+\S+;(?:\s+\S+\s+\S+;)*$') -rule_re = re.compile(r'^\s*(?P<src_kind>\S+)\s+(?P<allowed_origins>\S+)$') +csp_re = re.compile(r''' +^ +\S+(?:\s+\S+)+; # first directive +(?: + \s+\S+(?:\s+\S+)+; # subsequent directive +)* +$ +''', +re.VERBOSE) + +rule_re = re.compile(r''' +^ +\s* +(?P<src_kind>\S+) +\s+ +(?P<allowed_origins> + \S+(?:\s+\S+)* +) +$ +''', re.VERBOSE) + def parse_csp(csp): - ''' - Parsing of CSP string into a dict. A simplified format of CSP is assumed. - ''' + '''Parsing of CSP string into a dict.''' assert csp_re.match(csp) result = {} for rule in csp.split(';')[:-1]: match = rule_re.match(rule) - result[match.group('src_kind')] = match.group('allowed_origins') + result[match.group('src_kind')] = match.group('allowed_origins').split() return result @@ -78,10 +95,10 @@ def test_decide_policy(execute_in_page): for prop in ('mapping', 'payload', 'nonce', 'error'): assert prop not in policy assert parse_csp(policy['csp']) == { - 'prefetch-src': "'none'", - 'script-src-attr': "'none'", - 'script-src': "'none'", - 'script-src-elem': "'none'" + 'prefetch-src': ["'none'"], + 'script-src-attr': ["'none'"], + 'script-src': ["'none'", "'unsafe-eval'"], + 'script-src-elem': ["'none'"] } policy = execute_in_page( @@ -95,10 +112,10 @@ def test_decide_policy(execute_in_page): for prop in ('payload', 'nonce', 'error'): assert prop not in policy assert parse_csp(policy['csp']) == { - 'prefetch-src': "'none'", - 'script-src-attr': "'none'", - 'script-src': "'none'", - 'script-src-elem': "'none'" + 'prefetch-src': ["'none'"], + 'script-src-attr': ["'none'"], + 'script-src': ["'none'", "'unsafe-eval'"], + 'script-src-elem': ["'none'"] } policy = execute_in_page( @@ -114,10 +131,10 @@ def test_decide_policy(execute_in_page): assert policy['nonce'] == \ sha256('m1:res1:http://kno.wn/:abcd'.encode()).digest().hex() assert parse_csp(policy['csp']) == { - 'prefetch-src': f"'none'", - 'script-src-attr': f"'none'", - 'script-src': f"'nonce-{policy['nonce']}'", - 'script-src-elem': f"'nonce-{policy['nonce']}'" + 'prefetch-src': ["'none'"], + 'script-src-attr': ["'none'"], + 'script-src': [f"'nonce-{policy['nonce']}'", "'unsafe-eval'"], + 'script-src-elem': [f"'nonce-{policy['nonce']}'"] } policy = execute_in_page( @@ -128,8 +145,8 @@ def test_decide_policy(execute_in_page): for prop in ('mapping', 'payload', 'nonce'): assert prop not in policy assert parse_csp(policy['csp']) == { - 'prefetch-src': "'none'", - 'script-src-attr': "'none'", - 'script-src': "'none'", - 'script-src-elem': "'none'" + 'prefetch-src': ["'none'"], + 'script-src-attr': ["'none'"], + 'script-src': ["'none'", "'unsafe-eval'"], + 'script-src-elem': ["'none'"] } diff --git a/test/haketilo_test/unit/test_policy_enforcing.py b/test/haketilo_test/unit/test_policy_enforcing.py index bbc3eb9..4bc6470 100644 --- a/test/haketilo_test/unit/test_policy_enforcing.py +++ b/test/haketilo_test/unit/test_policy_enforcing.py @@ -31,15 +31,19 @@ nonce = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' allow_policy = {'allow': True} block_policy = { 'allow': False, - 'csp': f"prefetch-src 'none'; script-src-attr 'none'; script-src 'none'; script-src-elem 'none'; frame-src http://* https://*;" + 'csp': f"prefetch-src 'none'; script-src-attr 'none'; script-src 'none' 'unsafe-eval'; script-src-elem 'none'; frame-src http://* https://*;" } payload_policy = { 'mapping': 'somemapping', 'payload': {'identifier': 'someresource'}, - 'csp': f"prefetch-src 'none'; script-src-attr 'none'; script-src 'nonce-{nonce}'; script-src-elem 'nonce-{nonce}';" + 'csp': f"prefetch-src 'none'; script-src-attr 'none'; script-src 'nonce-{nonce}' 'unsafe-eval'; script-src-elem 'nonce-{nonce}';" } -content_script = load_script('content/policy_enforcing.js') + ''';{ +def content_script(): + return load_script('content/policy_enforcing.js') + \ + content_script_appended_code + +content_script_appended_code = ''';{ const smuggled_what_to_do = /^[^#]*#?(.*)$/.exec(document.URL)[1]; const what_to_do = smuggled_what_to_do === "" ? {policy: {allow: true}} : JSON.parse(decodeURIComponent(smuggled_what_to_do)); 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/test_webrequest.py b/test/haketilo_test/unit/test_webrequest.py index 1244117..dc329b8 100644 --- a/test/haketilo_test/unit/test_webrequest.py +++ b/test/haketilo_test/unit/test_webrequest.py @@ -85,7 +85,7 @@ nonce = f'nonce-{sha256(nonce_source).digest().hex()}' payload_csp_header = { 'name': f'Content-Security-Policy', 'value': ("prefetch-src 'none'; script-src-attr 'none'; " - f"script-src '{nonce}'; script-src-elem '{nonce}';") + f"script-src '{nonce}' 'unsafe-eval'; script-src-elem '{nonce}';") } sample_payload_headers = [ @@ -107,7 +107,7 @@ sample_blocked_headers.append(sample_csp_header) sample_blocked_headers.append({ 'name': f'Content-Security-Policy', 'value': ("prefetch-src 'none'; script-src-attr 'none'; " - f"script-src 'none'; script-src-elem 'none';") + "script-src 'none' 'unsafe-eval'; script-src-elem 'none';") }) @pytest.mark.get_page('https://gotmyowndoma.in') diff --git a/test/haketilo_test/unit/utils.py b/test/haketilo_test/unit/utils.py index 7ddf92a..9b3e4a0 100644 --- a/test/haketilo_test/unit/utils.py +++ b/test/haketilo_test/unit/utils.py @@ -228,12 +228,21 @@ def are_scripts_allowed(driver, nonce=None): return driver.execute_script( ''' document.haketilo_scripts_allowed = false; + document.haketilo_eval_allowed = false; const html_ns = "http://www.w3.org/1999/xhtml"; const script = document.createElementNS(html_ns, "script"); - script.innerHTML = "document.haketilo_scripts_allowed = true;"; + script.innerHTML = ` + document.haketilo_scripts_allowed = true; + eval('document.haketilo_eval_allowed = true;'); + `; if (arguments[0]) script.setAttribute("nonce", arguments[0]); (document.head || document.documentElement).append(script); + + if (document.haketilo_scripts_allowed != + document.haketilo_eval_allowed) + throw "scripts allowed but eval blocked"; + return document.haketilo_scripts_allowed; ''', nonce) @@ -246,36 +255,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); - - /* 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 result = {ok: response.ok, status: response.status}; - try { - result.json = await response.json(); - } catch(e) { - result.error_json = "" + e; - } - return result; - } +""" +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(""); - browser.tabs.sendMessage = new_sendMessage; - }''') +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); + + /* + * 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)) + } + } catch(e) { + return {error: {name: e.name, message: e.message}}; + } +} +}''' """ Convenience snippet of code to retrieve a copy of given object with only those diff --git a/test/haketilo_test/world_wide_library.py b/test/haketilo_test/world_wide_library.py index 1a90c42..92ce97e 100644 --- a/test/haketilo_test/world_wide_library.py +++ b/test/haketilo_test/world_wide_library.py @@ -33,6 +33,8 @@ from shutil import rmtree from threading import Lock from uuid import uuid4 import json +import functools as ft +import operator as op from .misc_constants import here from .unit.utils import * # sample repo data @@ -114,7 +116,7 @@ sample_contents = [f'Mi povas manĝi vitron, ĝi ne damaĝas min {i}' for i in range(9)] sample_hashes = [sha256(c.encode()).digest().hex() for c in sample_contents] -file_url = lambda hashed: f'https://hydril.la/file/sha256/{hashed}' +file_url = ft.partial(op.concat, 'https://hydril.la/file/sha256/') sample_files_catalog = dict([(file_url(h), make_handler(c)) for h, c in zip(sample_hashes, sample_contents)]) @@ -144,18 +146,37 @@ for i in range(10): 'dependencies': [] }) +# The one below will generate items with schema still at version 1, so required +# mappings will be ignored. +sample_resource_templates.append({ + 'id_suffix': 'a-w-required-mapping-v1', + 'files_count': 1, + 'dependencies': [], + 'required_mappings': [{'identifier': 'mapping-a'}], + 'include_in_query': False +}) + +sample_resource_templates.append({ + 'id_suffix': 'a-w-required-mapping-v2', + 'files_count': 1, + 'dependencies': [], + 'required_mappings': [{'identifier': 'mapping-a'}], + 'schema_ver': '2', + 'include_in_query': False +}) + sample_resources_catalog = {} sample_mappings_catalog = {} sample_queries = {} for srt in sample_resource_templates: resource = make_sample_resource() - resource['identifier'] = f'resource-{srt["id_suffix"]}' - resource['long_name'] = resource['identifier'].upper() - resource['uuid'] = str(uuid4()) - resource['dependencies'] = srt['dependencies'] - resource['source_copyright'] = [] - resource['scripts'] = [] + resource['identifier'] = f'resource-{srt["id_suffix"]}' + resource['long_name'] = resource['identifier'].upper() + resource['uuid'] = str(uuid4()) + resource['dependencies'] = srt['dependencies'] + resource['source_copyright'] = [] + resource['scripts'] = [] for i in range(srt['files_count']): file_ref = {'file': f'file_{i}', 'sha256': sample_hashes[i]} resource[('source_copyright', 'scripts')[i & 1]].append(file_ref) @@ -174,22 +195,25 @@ for srt in sample_resource_templates: sufs = [srt["id_suffix"], *[l for l in srt["id_suffix"] if l.isalpha()]] patterns = [f'https://example_{suf}.com/*' for suf in set(sufs)] - payloads = {} + mapping['payloads'] = {} for pat in patterns: - payloads[pat] = {'identifier': resource['identifier']} + mapping['payloads'][pat] = {'identifier': resource['identifier']} - queryable_url = pat.replace('*', 'something') - if queryable_url not in sample_queries: - sample_queries[queryable_url] = [] + if not srt.get('include_in_query', True): + continue - sample_queries[queryable_url].append({ + sample_queries.setdefault(pat.replace('*', 'something'), []).append({ 'identifier': mapping['identifier'], 'long_name': mapping['long_name'], 'version': mapping_versions[1] }) - mapping['payloads'] = payloads + for item in resource, mapping: + if 'required_mappings' in srt: + item['required_mappings'] = srt['required_mappings'] + if 'schema_ver' in srt: + item['$schema'] = item['$schema'].replace('1', srt['schema_ver']) for item, versions, catalog in [ (resource, resource_versions, sample_resources_catalog), |