From 299864ee0901df8db2314cc7c07d6c481927c8aa Mon Sep 17 00:00:00 2001 From: Wojtek Kosior Date: Thu, 13 Jan 2022 16:47:07 +0100 Subject: facilitate managing script blocking with a list of edtable entries --- common/patterns.js | 30 ++++++- html/payload_create.js | 17 +--- html/text_entry_list.js | 134 +++++++++++++++++++++++++++++--- test/unit/test_payload_create.py | 6 +- test/unit/test_text_entry_list.py | 159 ++++++++++++++++++++++++++++---------- test/unit/utils.py | 3 + 6 files changed, 281 insertions(+), 68 deletions(-) diff --git a/common/patterns.js b/common/patterns.js index 1398961..6f5aa40 100644 --- a/common/patterns.js +++ b/common/patterns.js @@ -186,5 +186,31 @@ function* each_url_pattern(url) } #EXPORT each_url_pattern -#EXPORT "https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns" \ - AS patterns_doc_url +const patterns_doc_url = + "https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns"; +#EXPORT patterns_doc_url + +function reconstruct_url(deco) +{ + const domain = deco.domain.join("."); + const path = ["", ...deco.path].join("/"); + const trail = deco.trailing_slash ? "/" : ""; + return `${deco.proto}://${domain}${path}${trail}`; +} +#EXPORT reconstruct_url + +function validate_normalize_url_pattern(url_pattern) +{ + try { + return reconstruct_url(deconstruct_url(url_pattern)); + } catch(e) { + const patterns_doc_link = document.createElement("a"); + patterns_doc_link.href = patterns_doc_url; + patterns_doc_link.innerText = "here"; + const msg = document.createElement("span"); + msg.prepend(`'${url_pattern}' is not a valid URL pattern. See `, + patterns_doc_link, " for more details."); + throw msg; + } +} +#EXPORT validate_normalize_url_pattern diff --git a/html/payload_create.js b/html/payload_create.js index c1563ae..503a461 100644 --- a/html/payload_create.js +++ b/html/payload_create.js @@ -46,7 +46,8 @@ #FROM html/DOM_helpers.js IMPORT clone_template #FROM common/sha256.js IMPORT sha256 -#FROM common/patterns.js IMPORT deconstruct_url, patterns_doc_url +#FROM common/patterns.js IMPORT validate_normalize_url_pattern, \ + patterns_doc_url /* https://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid */ /* This is a helper function used by uuidv4(). */ @@ -81,18 +82,8 @@ function collect_form_data(form_ctx) const payloads = {}; - for (const pattern of url_patterns) { - try { - deconstruct_url(pattern); - } catch(e) { - const patterns_doc_link = document.createElement("a"); - patterns_doc_link.href = patterns_doc_url; - patterns_doc_link.innerText = "here"; - const msg = document.createElement("span"); - msg.prepend(`'${pattern}' is not a valid URL pattern. See `, - patterns_doc_link, " for more details."); - throw msg; - } + for (let pattern of url_patterns) { + pattern = validate_normalize_url_pattern(pattern); if (pattern in payloads) throw `Pattern '${pattern}' specified multiple times!`; diff --git a/html/text_entry_list.js b/html/text_entry_list.js index 4af19fd..ff63c79 100644 --- a/html/text_entry_list.js +++ b/html/text_entry_list.js @@ -46,7 +46,7 @@ #IMPORT common/indexeddb.js AS haketilodb #FROM html/DOM_helpers.js IMPORT clone_template -#FROM common/patterns.js IMPORT deconstruct_url, patterns_doc_url +#FROM common/patterns.js IMPORT validate_normalize_url_pattern const coll = new Intl.Collator(); @@ -232,7 +232,7 @@ function TextEntryList(dialog_ctx, destroy_cb, async function repo_list(dialog_ctx) { let list; - function validate_normalize_repo_url(repo_url) { + function validate_normalize(repo_url) { let error_msg; /* In the future we might also try making a test connection. */ @@ -251,30 +251,32 @@ async function repo_list(dialog_ctx) { } async function remove_repo(repo_url) { - dialog.loader(dialog_ctx, "Removing repository..."); + dialog.loader(dialog_ctx, `Removing repository '${repo_url}'...`); try { await haketilodb.del_repo(repo_url); var removing_ok = true; } finally { - if (!removing_ok) - dialog.error(dialog_ctx, "Failed to remove repository :("); + if (!removing_ok) { + dialog.error(dialog_ctx, + `Failed to remove repository '${repo_url}' :(`); + } dialog.close(dialog_ctx); } } async function create_repo(repo_url) { - repo_url = validate_normalize_repo_url(repo_url); + repo_url = validate_normalize(repo_url); - dialog.loader(dialog_ctx, "Adding repository..."); + dialog.loader(dialog_ctx, `Adding repository '${repo_url}'...`); try { await haketilodb.set_repo(repo_url); var adding_ok = true; } finally { if (!adding_ok) - dialog.error(dialog_ctx, "Failed to add repository :("); + dialog.error(dialog_ctx, `Failed to add repository '${repo_url}' :(`); dialog.close(dialog_ctx); } @@ -284,7 +286,7 @@ async function repo_list(dialog_ctx) { if (old_repo_url === new_repo_url) return; - new_repo_url = validate_normalize_repo_url(new_repo_url); + new_repo_url = validate_normalize(new_repo_url); dialog.loader(dialog_ctx, "Replacing repository..."); @@ -319,3 +321,117 @@ async function repo_list(dialog_ctx) { return list; } #EXPORT repo_list + +async function blocking_allowing_lists(dialog_ctx) { + function validate_normalize(url_pattern) { + try { + return validate_normalize_url_pattern(url_pattern); + } catch(e) { + dialog.error(dialog_ctx, e); + throw e; + } + } + + async function default_allowing_on(url_pattern, allow) { + dialog.loader(dialog_ctx, + `Setting default scripts blocking policy on '${url_pattern}'...`); + + try { + await haketilodb.set_default_allowing(url_pattern); + var default_allowing_ok = true; + } finally { + if (!default_allowing_ok) { + dialog.error(dialog_ctx, + `Failed to remove rule for '${url_pattern}' :(`); + } + + dialog.close(dialog_ctx); + } + } + + async function set_allowing_on(url_pattern, allow) { + url_pattern = validate_normalize(url_pattern); + + const [action, action_cap] = allow ? + ["allowing", "Allowing"] : ["blocking", "Blocking"]; + dialog.loader(dialog_ctx, `${action_cap} scripts on '${url_pattern}'...`); + + try { + await haketilodb.set_allowed(url_pattern, allow); + var set_allowing_ok = true; + } finally { + if (!set_allowing_ok) + dialog.error(dialog_ctx, + `Failed to write ${action} rule for '${url_pattern}' :(`); + + dialog.close(dialog_ctx); + } + } + + async function replace_allowing_on(old_pattern, new_pattern, allow) { + new_pattern = validate_normalize(new_pattern); + if (old_pattern === new_pattern) + return; + + const action = allow ? "allowing" : "blocking"; + dialog.loader(dialog_ctx, `Rewriting script ${action} rule...`); + + try { + await haketilodb.set_allowed(new_pattern, allow); + await haketilodb.set_default_allowing(old_pattern); + var replace_allowing_ok = true; + } finally { + if (!replace_allowing_ok) + dialog.error(dialog_ctx, `Failed to rewrite ${action} rule :(`); + + dialog.close(dialog_ctx); + } + } + + let blocking_list, allowing_list; + + function onchange(change) { + if (change.new_val) { + if (change.new_val.allow) + var [to_add, to_remove] = [allowing_list, blocking_list]; + else + var [to_add, to_remove] = [blocking_list, allowing_list]; + + to_add.add(change.key); + to_remove.remove(change.key); + } else { + blocking_list.remove(change.key); + allowing_list.remove(change.key); + } + } + + dialog.loader(dialog_ctx, "Loading script blocking settings..."); + const [tracking, items] = await haketilodb.track.blocking(onchange); + dialog.close(dialog_ctx); + + let untrack_called = 0; + function untrack() { + if (++untrack_called === 2) + haketilodb.untrack(tracking); + } + + const lists = []; + for (const allow of [false, true]) { + lists[allow + 0] = + new TextEntryList(dialog_ctx, untrack, + pattern => default_allowing_on(pattern), + pattern => set_allowing_on(pattern, allow), + (p1, p2) => replace_allowing_on(p1, p2, allow)); + } + [blocking_list, allowing_list] = lists; + + for (const item of items) { + if (item.allow) + allowing_list.add(item.pattern); + else + blocking_list.add(item.pattern); + } + + return lists; +} +#EXPORT blocking_allowing_lists diff --git a/test/unit/test_payload_create.py b/test/unit/test_payload_create.py index 569d088..f07083d 100644 --- a/test/unit/test_payload_create.py +++ b/test/unit/test_payload_create.py @@ -93,7 +93,7 @@ def test_payload_create_normal_usage(driver, execute_in_page): form_ctx.dialog_container]); ''') - assert doc_url == \ + assert patterns_doc_url == \ driver.find_element_by_link_text('URL patterns').get_attribute('href') assert form_container.is_displayed() @@ -176,8 +176,6 @@ def test_payload_create_normal_usage(driver, execute_in_page): assert_db_contents() -doc_url = 'https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns' - @pytest.mark.ext_data({ 'background_script': broker_js, 'extra_html': ExtraHTML('html/payload_create.html', {}), @@ -215,7 +213,7 @@ def test_payload_create_errors(driver, execute_in_page): # Verify patterns documentation link. if expected_msg == {'patterns': ':d'}: doc_link_elem = driver.find_element_by_link_text('here') - assert doc_link.get_attribute('href') == doc_url + assert doc_link.get_attribute('href') == patterns_doc_url # Verify the form was NOT cleared upon failed saving. execute_in_page('form_ctx.dialog_ctx.ok_but.click();') diff --git a/test/unit/test_text_entry_list.py b/test/unit/test_text_entry_list.py index 1951d53..e04f36c 100644 --- a/test/unit/test_text_entry_list.py +++ b/test/unit/test_text_entry_list.py @@ -28,12 +28,23 @@ from .utils import * broker_js = lambda: load_script('background/broadcast_broker.js') + ';start();' +list_code_template = '(await blocking_allowing_lists(%%s))[%d]' +mode_parameters = [ + #add_action del_action instantiate_code + ('set_repo', 'del_repo', 'await repo_list(%s)'), + ('set_disallowed', 'set_default_allowing', list_code_template % 0), + ('set_disallowed', 'set_allowed', list_code_template % 0), + ('set_allowed', 'set_default_allowing', list_code_template % 1), + ('set_allowed', 'set_disallowed', list_code_template % 1) +] + def instantiate_list(to_return): + instantiate_code = inspect.stack()[1].frame.f_locals['instantiate_code'] return inspect.stack()[1].frame.f_locals['execute_in_page']( f''' let dialog_ctx = dialog.make(() => {{}}, () => {{}}), list; async function make_list() {{ - list = await repo_list(dialog_ctx); + list = {instantiate_code % 'dialog_ctx'}; document.body.append(list.main_div, dialog_ctx.main_div); return [{', '.join(to_return)}]; }} @@ -49,10 +60,13 @@ dialog_html_test_ext_data = { @pytest.mark.ext_data(dialog_html_test_ext_data) @pytest.mark.usefixtures('webextension') -def test_text_entry_list_ordering(driver, execute_in_page): +@pytest.mark.parametrize('mode', mode_parameters) +def test_text_entry_list_ordering(driver, execute_in_page, mode): """ - A test case of ordering of repo URLs in the list. + A test case of ordering of repo URLs or URL patterns in the list. """ + add_action, del_action, instantiate_code = mode + execute_in_page(load_script('html/text_entry_list.js')) endings = ['hyd/', 'hydrilla/', 'Hydrilla/', 'HYDRILLA/', @@ -75,23 +89,26 @@ def test_text_entry_list_ordering(driver, execute_in_page): def add_urls(): execute_in_page( '''{ - async function add_urls(urls) { + async function add_urls(urls, add_action) { for (const url of urls) - await haketilodb.set_repo(url); + await haketilodb[add_action](url); } - returnval(add_urls(arguments[0])); + returnval(add_urls(...arguments)); }''', - urls) + urls, add_action) def wait_for_completed(wait_id): """ - We add an extra repo url to IndexedDB and wait for it to appear in - the DOM list. Once this happes, we know other operations must have - also finished. + We add an extra url to IndexedDB and wait for it to appear in the + DOM list. Once this happes, we know other operations must have also + finished. """ url = f'https://example.org/{iteration}/{wait_id}' - execute_in_page('returnval(haketilodb.set_repo(arguments[0]));', - url) + execute_in_page( + ''' + returnval(haketilodb[arguments[1]](arguments[0])); + ''', + url, add_action) WebDriverWait(driver, 10).until(lambda _: url in list_div.text) def assert_order(indexes_present, empty_entry_expected=False): @@ -122,13 +139,13 @@ def test_text_entry_list_ordering(driver, execute_in_page): execute_in_page( '''{ - async function remove_urls(urls) { + async function remove_urls(urls, del_action) { for (const url of urls) - await haketilodb.del_repo(url); + await haketilodb[del_action](url); } - returnval(remove_urls(arguments[0])); + returnval(remove_urls(...arguments)); }''', - urls) + urls, del_action) wait_for_completed(1) assert_order(indexes_added.difference(to_include)) @@ -148,16 +165,20 @@ def existing(id, entry_nr=0): return inspect.stack()[1].frame.f_locals['execute_in_page']( ''' returnval(list.entries_by_text.get(list.shown_texts[arguments[0]])\ - [arguments[1]]); + [arguments[1]]); ''', entry_nr, id) @pytest.mark.ext_data(dialog_html_test_ext_data) @pytest.mark.usefixtures('webextension') -def test_text_entry_list_editing(driver, execute_in_page): +@pytest.mark.parametrize('mode', [mp for mp in mode_parameters + if mp[1] != 'set_default_allowing']) +def test_text_entry_list_editing(driver, execute_in_page, mode): """ A test case of editing entries in repo URLs list. """ + add_action, _, instantiate_code = mode + execute_in_page(load_script('html/text_entry_list.js')) execute_in_page( @@ -173,7 +194,11 @@ def test_text_entry_list_editing(driver, execute_in_page): list_div, new_entry_but = \ instantiate_list(['list.list_div', 'list.new_but']) - assert last_loader_msg() == ['Loading repositories...'] + if 'allow' in add_action: + assert last_loader_msg() == ['Loading script blocking settings...'] + else: + assert last_loader_msg() == ['Loading repositories...'] + assert execute_in_page('returnval(dialog_ctx.shown);') == False # Test adding new item. Submit via button click. @@ -184,28 +209,60 @@ def test_text_entry_list_editing(driver, execute_in_page): assert active('cancel_but').is_displayed() active('input').send_keys('https://example.com///') active('add_but').click() - WebDriverWait(driver, 10).until(lambda _: 'example.com/' in list_div.text) + WebDriverWait(driver, 10).until(lambda _: 'example.com' in list_div.text) assert execute_in_page('returnval(list.list_div.children.length);') == 1 - assert last_loader_msg() == ['Adding repository...'] + if 'disallow' in add_action: + assert last_loader_msg() == \ + ["Blocking scripts on 'https://example.com/'..."] + elif 'allow' in add_action: + assert last_loader_msg() == \ + ["Allowing scripts on 'https://example.com/'..."] + else: + assert last_loader_msg() == \ + ["Adding repository 'https://example.com/'..."] assert not existing('editable_view').is_displayed() assert existing('text').is_displayed() assert existing('remove_but').is_displayed() - # Test editing item. Submit via 'Enter' hit. + # Test editing item. Submit via 'Enter' hit. Also test url pattern + # normalization. existing('text').click() assert not active('noneditable_view').is_displayed() assert not active('add_but').is_displayed() assert active('save_but').is_displayed() assert active('cancel_but').is_displayed() assert active('input.value') == 'https://example.com/' - active('input').send_keys(Keys.BACKSPACE * 30 + 'https://example.org//' + active('input').send_keys(Keys.BACKSPACE * 30 + 'https://example.org//a//b' + Keys.ENTER) - WebDriverWait(driver, 10).until(lambda _: 'example.org/' in list_div.text) + WebDriverWait(driver, 10).until(lambda _: 'example.org' in list_div.text) assert execute_in_page('returnval(list.list_div.children.length);') == 1 - assert last_loader_msg() == ['Replacing repository...'] + if 'disallow' in add_action: + assert last_loader_msg() == ['Rewriting script blocking rule...'] + elif 'allow' in add_action: + assert last_loader_msg() == ['Rewriting script allowing rule...'] + else: + assert last_loader_msg() == ['Replacing repository...'] + + # Test entry removal. + existing('remove_but').click() + WebDriverWait(driver, 10).until(lambda _: 'xample.org' not in list_div.text) + assert execute_in_page('returnval(list.list_div.children.length);') == 0 + if 'allow' in add_action: + assert last_loader_msg() == \ + ["Setting default scripts blocking policy on 'https://example.org/a/b'..."] + else: + assert last_loader_msg() == ["Removing repository 'https://example.org//a//b/'..."] + + # The rest of this test remains the same regardless of mode. No point + # testing the same thing multiple times. + if 'repo' not in add_action: + return # Test that clicking hidden buttons of item not being edited does nothing. + new_entry_but.click() + active('input').send_keys('https://example.foo' + Keys.ENTER) + WebDriverWait(driver, 10).until(lambda _: 'xample.foo/' in list_div.text) existing('add_but.click()') existing('save_but.click()') existing('cancel_but.click()') @@ -237,7 +294,7 @@ def test_text_entry_list_editing(driver, execute_in_page): assert existing('editable_view').is_displayed() assert execute_in_page('returnval(list.list_div.children.length);') == 1 - # Test that starting edit of another entry cancels edit of the other entry. + # Test that starting edit of another entry cancels edit of the first entry. new_entry_but.click() active('input').send_keys('https://example.net' + Keys.ENTER) WebDriverWait(driver, 10).until(lambda _: 'example.net/' in list_div.text) @@ -249,18 +306,16 @@ def test_text_entry_list_editing(driver, execute_in_page): assert not existing('editable_view', 0).is_displayed() assert existing('editable_view', 1).is_displayed() - # Test entry removal. - existing('remove_but', 0).click() - WebDriverWait(driver, 10).until(lambda _: 'mple.net/' not in list_div.text) - assert execute_in_page('returnval(list.list_div.children.length);') == 1 - assert last_loader_msg() == ['Removing repository...'] - @pytest.mark.ext_data(dialog_html_test_ext_data) @pytest.mark.usefixtures('webextension') -def test_text_entry_list_errors(driver, execute_in_page): +@pytest.mark.parametrize('mode', [mp for mp in mode_parameters + if mp[1] != 'set_default_allowing']) +def test_text_entry_list_errors(driver, execute_in_page, mode): """ A test case of error dialogs shown by repo URL list. """ + add_action, _, instantiate_code = mode + execute_in_page(load_script('html/text_entry_list.js')) to_return = ['list.list_div', 'list.new_but', 'dialog_ctx.main_div'] @@ -275,9 +330,18 @@ def test_text_entry_list_errors(driver, execute_in_page): clickable.click() active('input').send_keys(Keys.BACKSPACE * 30 + 'ws://example' + Keys.ENTER) - assert 'Repository URLs shoud use https:// schema.' in dialog_div.text execute_in_page('dialog.close(dialog_ctx);') + if 'allow' in add_action: + assert "'ws://example' is not a valid URL pattern. See here for more details." \ + in dialog_div.text + assert patterns_doc_url == \ + driver.find_element_by_link_text('here').get_attribute('href') + continue + else: + assert 'Repository URLs shoud use https:// schema.' \ + in dialog_div.text + active('input').send_keys(Keys.BACKSPACE * 30 + 'https://example' + Keys.ENTER) assert 'Provided URL is not valid.' in dialog_div.text @@ -286,25 +350,40 @@ def test_text_entry_list_errors(driver, execute_in_page): # Mock errors to force error messages to appear. execute_in_page( ''' - for (const action of ["set_repo", "del_repo"]) + for (const action of [ + "set_repo", "del_repo", "set_allowed", "set_default_allowing" + ]) haketilodb[action] = () => {throw "reckless, limitless scope";}; ''') # Check database error dialogs. - def check_reported_failure(what): - fail = lambda _: f'Failed to {what} repository :(' in dialog_div.text + def check_reported_failure(txt): + fail = lambda _: txt in dialog_div.text WebDriverWait(driver, 10).until(fail) execute_in_page('dialog.close(dialog_ctx);') existing('text').click() active('input').send_keys(Keys.BACKSPACE * 30 + 'https://example.org' + Keys.ENTER) - check_reported_failure('replace') + if 'disallow' in add_action: + check_reported_failure('Failed to rewrite blocking rule :(') + elif 'allow' in add_action: + check_reported_failure('Failed to rewrite allowing rule :(') + else: + check_reported_failure('Failed to replace repository :(') active('cancel_but').click() existing('remove_but').click() - check_reported_failure('remove') + if 'allow' in add_action: + check_reported_failure("Failed to remove rule for 'https://example.com' :(") + else: + check_reported_failure("Failed to remove repository 'https://example.com/' :(") new_entry_but.click() active('input').send_keys('https://example.org' + Keys.ENTER) - check_reported_failure('add') + if 'disallow' in add_action: + check_reported_failure("Failed to write blocking rule for 'https://example.org' :(") + elif 'allow' in add_action: + check_reported_failure("Failed to write allowing rule for 'https://example.org' :(") + else: + check_reported_failure("Failed to add repository 'https://example.org/' :(") diff --git a/test/unit/utils.py b/test/unit/utils.py index a35e587..6abb879 100644 --- a/test/unit/utils.py +++ b/test/unit/utils.py @@ -27,6 +27,9 @@ Various functions and objects that can be reused between unit tests from hashlib import sha256 +patterns_doc_url = \ + 'https://hydrillabugs.koszko.org/projects/haketilo/wiki/URL_patterns' + def make_hash_key(file_contents): return f'sha256-{sha256(file_contents.encode()).digest().hex()}' -- cgit v1.2.3