aboutsummaryrefslogtreecommitdiff
# SPDX-License-Identifier: CC0-1.0

"""
Haketilo unit tests - list of editable entries
"""

# 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
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.common.keys import Keys
import inspect

from ..extension_crafting import ExtraHTML
from ..script_loader import load_script
from .utils import *

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 = {instantiate_code % 'dialog_ctx'};
            document.body.append(list.main_div, dialog_ctx.main_div);
            return [{', '.join(to_return)}];
        }}
        returnval(make_list());
        ''')

dialog_html_append = {'html/text_entry_list.html': '#INCLUDE html/dialog.html'}
dialog_html_test_ext_data = {
    'background_script': broker_js,
    'extra_html': ExtraHTML('html/text_entry_list.html', dialog_html_append),
    'navigate_to': 'html/text_entry_list.html'
}

@pytest.mark.ext_data(dialog_html_test_ext_data)
@pytest.mark.usefixtures('webextension')
@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 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/',
               'test/', 'test^it/', 'Test^it/', 'TEST^IT/']

    indexes_added = set()

    for iteration, to_include in enumerate([
            set([i for i in range(len(endings)) if is_prime(i)]),
            set([i for i in range(len(endings))
                 if not is_prime(i) and i & 1]),
            set([i for i in range(len(endings)) if i % 3 == 0]),
            set([i for i in range(len(endings))
                 if i % 3 and not i & 1 and not is_prime(i)]),
            set(range(len(endings)))
    ]):
        endings_to_include = [endings[i] for i in sorted(to_include)]
        urls = [f'https://example.com/{e}' for e in endings_to_include]

        def add_urls():
            execute_in_page(
                '''{
                async function add_urls(urls, add_action) {
                    for (const url of urls)
                        await haketilodb[add_action](url);
                }
                returnval(add_urls(...arguments));
                }''',
                urls, add_action)

        def wait_for_completed(wait_id):
            """
            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[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):
            entries_texts = execute_in_page(
                '''
                returnval([...list.list_div.children].map(n => n.textContent));
                ''')

            if empty_entry_expected:
                assert 'example' not in entries_texts[0]
                entries_texts.pop(0)

            for i, et in zip(sorted(indexes_present), entries_texts):
                assert f'https://example.com/{endings[i]}' in et

            for et in entries_texts[len(indexes_present):]:
                assert 'example.org' in et

        add_urls()

        if iteration == 0:
            list_div, new_entry_but = \
                instantiate_list(['list.list_div', 'list.new_but'])

        indexes_added.update(to_include)
        wait_for_completed(0)
        assert_order(indexes_added)

        execute_in_page(
            '''{
            async function remove_urls(urls, del_action) {
                for (const url of urls)
                    await haketilodb[del_action](url);
            }
            returnval(remove_urls(...arguments));
            }''',
            urls, del_action)
        wait_for_completed(1)
        assert_order(indexes_added.difference(to_include))

        # On the last iteration, add a new editable entry before re-additions.
        if len(to_include) == len(endings):
            new_entry_but.click()
            add_urls()
            wait_for_completed(2)
            assert_order(indexes_added, empty_entry_expected=True)
        else:
            add_urls()

def active(id):
    return inspect.stack()[1].frame.f_locals['execute_in_page']\
        (f'returnval(list.active_entry.{id});')
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]]);
        ''',
        entry_nr, id)

@pytest.mark.ext_data(dialog_html_test_ext_data)
@pytest.mark.usefixtures('webextension')
@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(
        '''
        let original_loader = dialog.loader, last_loader_msg;
        dialog.loader = (ctx, ...msg) => {
            last_loader_msg = msg;
            return original_loader(ctx, ...msg);
        }
        ''')
    last_loader_msg = lambda: execute_in_page('returnval(last_loader_msg);')

    list_div, new_entry_but = \
        instantiate_list(['list.list_div', 'list.new_but'])

    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.
    new_entry_but.click()
    assert not active('noneditable_view').is_displayed()
    assert not active('save_but').is_displayed()
    assert active('add_but').is_displayed()
    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)
    assert execute_in_page('returnval(list.list_div.children.length);') == 1
    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. 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//a//b'
                              + Keys.ENTER)
    WebDriverWait(driver, 10).until(lambda _: 'example.org' in list_div.text)
    assert execute_in_page('returnval(list.list_div.children.length);') == 1
    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()')
    assert execute_in_page('returnval(dialog_ctx.shown);') == False
    assert execute_in_page('returnval(list.list_div.children.length);') == 1
    assert not existing('editable_view').is_displayed()

    # Test that clicking hidden buttons of item being edited does nothing.
    existing('text').click()
    active('remove_but.click()')
    active('add_but.click()')
    assert execute_in_page('returnval(dialog_ctx.shown);') == False
    assert execute_in_page('returnval(list.list_div.children.length);') == 1
    assert not active('noneditable_view').is_displayed()

    # Test that creating a new entry makes the other one noneditable again.
    new_entry_but.click()
    assert existing('text').is_displayed()

    # Test that clicking hidden buttons of new item entry does nothing.
    active('remove_but.click()')
    active('save_but.click()')
    assert execute_in_page('returnval(dialog_ctx.shown);') == False
    assert execute_in_page('returnval(list.list_div.children.length);') == 2
    assert not active('noneditable_view').is_displayed()

    # Test that starting edit of another entry removes the new entry.
    existing('text').click()
    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 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)
    assert execute_in_page('returnval(list.list_div.children.length);') == 2
    existing('text', 0).click()
    assert existing('editable_view', 0).is_displayed()
    assert not existing('editable_view', 1).is_displayed()
    existing('text', 1).click()
    assert not existing('editable_view', 0).is_displayed()
    assert existing('editable_view', 1).is_displayed()

@pytest.mark.ext_data(dialog_html_test_ext_data)
@pytest.mark.usefixtures('webextension')
@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']
    list_div, new_entry_but, dialog_div = instantiate_list(to_return)

    # Prepare one entry to use later.
    new_entry_but.click()
    active('input').send_keys('https://example.com' + Keys.ENTER)

    # Check invalid URL errors.
    for clickable in (existing('text'), new_entry_but):
        clickable.click()
        active('input').send_keys(Keys.BACKSPACE * 30 + 'ws://example'
                                  + Keys.ENTER)
        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
        execute_in_page('dialog.close(dialog_ctx);')

    # Mock errors to force error messages to appear.
    execute_in_page(
        '''
        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(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)
    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()
    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)
    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/' :(")