From 80dff7bf9cb7f803203ecc6f2db0a31eb8ed9741 Mon Sep 17 00:00:00 2001 From: Sunil Mohan Adapa Date: Tue, 19 May 2020 23:54:55 -0700 Subject: [PATCH] tests: functional: Re-organize step definitions and helper methods - Move non-reusable app specific step definitions and helper methods into /tests/test_functional.py. - Merge reusable helper methods into plinth.tests.functional - Merge reusable step definitions into plinth.tests.functional.step_definitions - avahi, datetime, ikiwiki: Reuse common methods to avoid repetition. Avoid mapping from app nicknames to actual app names. - deluge, transmission: Make a copy of sample.torrent for each app to avoid clogging common place. - Implement functional.visit() to simplify a lot of browser.visit() calls. - Ensure that name of the mark on functional tests for an app is same as name of the app. This will help with predicting the mark when running tests for a particular app. Tests performed: - Run all functional tests. Signed-off-by: Sunil Mohan Adapa Reviewed-by: Joseph Nuthalapati --- conftest.py | 6 +- plinth/modules/avahi/tests/avahi.feature | 20 +- .../modules/backups/tests/test_functional.py | 84 +- plinth/modules/bind/tests/test_functional.py | 38 +- plinth/modules/config/tests/config.feature | 3 +- .../modules/config/tests/test_functional.py | 71 +- .../coquelicot/tests/test_functional.py | 120 ++- .../modules/datetime/tests/datetime.feature | 18 +- .../modules/datetime/tests/test_functional.py | 27 +- .../deluge/tests}/data/sample.torrent | Bin .../modules/deluge/tests/test_functional.py | 168 +++- .../dynamicdns/tests/test_functional.py | 67 +- .../modules/ejabberd/tests/test_functional.py | 86 +- .../modules/gitweb/tests/test_functional.py | 307 ++++++- plinth/modules/help/tests/test_functional.py | 22 +- plinth/modules/ikiwiki/tests/ikiwiki.feature | 22 +- .../modules/ikiwiki/tests/test_functional.py | 48 +- .../mediawiki/tests/test_functional.py | 212 ++++- .../modules/mldonkey/tests/test_functional.py | 56 +- .../monkeysphere/tests/test_functional.py | 75 +- .../modules/openvpn/tests/test_functional.py | 40 +- .../modules/pagekite/tests/test_functional.py | 41 +- .../modules/radicale/tests/test_functional.py | 114 ++- plinth/modules/samba/tests/test_functional.py | 113 ++- plinth/modules/searx/tests/test_functional.py | 33 +- .../modules/security/tests/test_functional.py | 39 +- .../shadowsocks/tests/test_functional.py | 39 +- .../modules/sharing/tests/test_functional.py | 141 +++- .../modules/snapshot/tests/test_functional.py | 121 ++- .../modules/storage/tests/test_functional.py | 19 +- .../syncthing/tests/test_functional.py | 137 +++- plinth/modules/tahoe/tests/test_functional.py | 68 +- plinth/modules/tor/tests/test_functional.py | 130 ++- .../transmission/tests/data/sample.torrent | Bin 0 -> 46608 bytes .../transmission/tests/test_functional.py | 63 +- plinth/modules/ttrss/tests/test_functional.py | 69 +- .../modules/upgrades/tests/test_functional.py | 43 +- plinth/modules/users/tests/test_functional.py | 136 +++- plinth/modules/users/tests/users.feature | 2 +- plinth/tests/functional/__init__.py | 448 +++++++++++ plinth/tests/functional/step_definitions.py | 163 ++++ .../functional/step_definitions/__init__.py | 0 .../step_definitions/application.py | 639 --------------- .../functional/step_definitions/interface.py | 90 --- .../functional/step_definitions/service.py | 37 - .../tests/functional/step_definitions/site.py | 234 ------ .../functional/step_definitions/system.py | 341 -------- plinth/tests/functional/support/__init__.py | 13 - .../tests/functional/support/application.py | 752 ------------------ plinth/tests/functional/support/interface.py | 150 ---- plinth/tests/functional/support/service.py | 79 -- plinth/tests/functional/support/site.py | 589 -------------- plinth/tests/functional/support/system.py | 417 ---------- 53 files changed, 3340 insertions(+), 3410 deletions(-) rename plinth/{tests/functional => modules/deluge/tests}/data/sample.torrent (100%) create mode 100644 plinth/modules/transmission/tests/data/sample.torrent create mode 100644 plinth/tests/functional/step_definitions.py delete mode 100644 plinth/tests/functional/step_definitions/__init__.py delete mode 100644 plinth/tests/functional/step_definitions/application.py delete mode 100644 plinth/tests/functional/step_definitions/interface.py delete mode 100644 plinth/tests/functional/step_definitions/service.py delete mode 100644 plinth/tests/functional/step_definitions/site.py delete mode 100644 plinth/tests/functional/step_definitions/system.py delete mode 100644 plinth/tests/functional/support/__init__.py delete mode 100644 plinth/tests/functional/support/application.py delete mode 100644 plinth/tests/functional/support/interface.py delete mode 100644 plinth/tests/functional/support/service.py delete mode 100644 plinth/tests/functional/support/site.py delete mode 100644 plinth/tests/functional/support/system.py diff --git a/conftest.py b/conftest.py index c70aa0843..873409358 100644 --- a/conftest.py +++ b/conftest.py @@ -15,11 +15,7 @@ try: except ImportError: _bdd_available = False else: - from plinth.tests.functional.step_definitions.application import * - from plinth.tests.functional.step_definitions.interface import * - from plinth.tests.functional.step_definitions.service import * - from plinth.tests.functional.step_definitions.site import * - from plinth.tests.functional.step_definitions.system import * + from plinth.tests.functional.step_definitions import * def pytest_ignore_collect(path, config): diff --git a/plinth/modules/avahi/tests/avahi.feature b/plinth/modules/avahi/tests/avahi.feature index c8606dec6..0ab01d1cb 100644 --- a/plinth/modules/avahi/tests/avahi.feature +++ b/plinth/modules/avahi/tests/avahi.feature @@ -1,18 +1,18 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -@system @essential @service_discovery -Feature: Service Discovery +@system @essential @avahi +Feature: Avahi Service Discovery Configure service discovery. Background: Given I'm a logged in user -Scenario: Disable service discovery application - Given the service discovery application is enabled - When I disable the service discovery application - Then the service discovery service should not be running +Scenario: Disable avahi application + Given the avahi application is enabled + When I disable the avahi application + Then the avahi service should not be running -Scenario: Enable service discovery application - Given the service discovery application is disabled - When I enable the service discovery application - Then the service discovery service should be running +Scenario: Enable avahi application + Given the avahi application is disabled + When I enable the avahi application + Then the avahi service should be running diff --git a/plinth/modules/backups/tests/test_functional.py b/plinth/modules/backups/tests/test_functional.py index 4c6a3171f..535c387bc 100644 --- a/plinth/modules/backups/tests/test_functional.py +++ b/plinth/modules/backups/tests/test_functional.py @@ -3,6 +3,88 @@ Functional, browser based tests for backups app. """ -from pytest_bdd import scenarios +import os +import tempfile +import urllib.parse + +import requests +from pytest import fixture +from pytest_bdd import parsers, scenarios, then, when + +from plinth.tests import functional scenarios('backups.feature') + + +@fixture(scope='session') +def downloaded_file_info(): + return dict() + + +@when(parsers.parse('I open the main page')) +def open_main_page(session_browser): + _open_main_page(session_browser) + + +@then(parsers.parse('the main page should be shown')) +def main_page_is_shown(session_browser): + assert (session_browser.url.endswith('/plinth/')) + + +@when( + parsers.parse('I download the app data backup with name {archive_name:w}')) +def backup_download(session_browser, downloaded_file_info, archive_name): + file_path = _download(session_browser, archive_name) + downloaded_file_info['path'] = file_path + + +@when(parsers.parse('I restore the downloaded app data backup')) +def backup_restore_from_upload(session_browser, app_name, + downloaded_file_info): + path = downloaded_file_info["path"] + try: + _upload_and_restore(session_browser, app_name, path) + except Exception as err: + raise err + finally: + os.remove(path) + + +def _open_main_page(browser): + with functional.wait_for_page_update(browser): + browser.find_link_by_href('/plinth/').first.click() + + +def _download_file_logged_in(browser, url, suffix=''): + """Download a file from Plinth, pretend being logged in via cookies""" + if not url.startswith("http"): + current_url = urllib.parse.urlparse(browser.url) + url = "%s://%s%s" % (current_url.scheme, current_url.netloc, url) + cookies = browser.driver.get_cookies() + cookies = {cookie["name"]: cookie["value"] for cookie in cookies} + response = requests.get(url, verify=False, cookies=cookies) + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file: + for chunk in response.iter_content(chunk_size=128): + temp_file.write(chunk) + return temp_file.name + + +def _download(browser, archive_name=None): + functional.nav_to_module(browser, 'backups') + href = f'/plinth/sys/backups/root/download/{archive_name}/' + url = functional.base_url + href + file_path = _download_file_logged_in(browser, url, suffix='.tar.gz') + return file_path + + +def _upload_and_restore(browser, app_name, downloaded_file_path): + functional.nav_to_module(browser, 'backups') + browser.find_link_by_href('/plinth/sys/backups/upload/').first.click() + fileinput = browser.driver.find_element_by_id('id_backups-file') + fileinput.send_keys(downloaded_file_path) + # submit upload form + functional.submit(browser) + # submit restore form + with functional.wait_for_page_update(browser, + expected_url='/plinth/sys/backups/'): + functional.submit(browser) diff --git a/plinth/modules/bind/tests/test_functional.py b/plinth/modules/bind/tests/test_functional.py index 4c5e69b5b..78e47183a 100644 --- a/plinth/modules/bind/tests/test_functional.py +++ b/plinth/modules/bind/tests/test_functional.py @@ -3,6 +3,42 @@ Functional, browser based tests for bind app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('bind.feature') + + +@given(parsers.parse('bind DNSSEC is {enable:w}')) +def bind_given_enable_dnssec(session_browser, enable): + should_enable = (enable == 'enabled') + _enable_dnssec(session_browser, should_enable) + + +@when(parsers.parse('I {enable:w} bind DNSSEC')) +def bind_enable_dnssec(session_browser, enable): + should_enable = (enable == 'enable') + _enable_dnssec(session_browser, should_enable) + + +@then(parsers.parse('bind DNSSEC should be {enabled:w}')) +def bind_assert_dnssec(session_browser, enabled): + assert _get_dnssec(session_browser) == (enabled == 'enabled') + + +def _enable_dnssec(browser, enable): + """Enable/disable DNSSEC in bind configuration.""" + functional.nav_to_module(browser, 'bind') + if enable: + browser.check('enable_dnssec') + else: + browser.uncheck('enable_dnssec') + + functional.submit(browser, form_class='form-configuration') + + +def _get_dnssec(browser): + """Return whether DNSSEC is enabled/disabled in bind configuration.""" + functional.nav_to_module(browser, 'bind') + return browser.find_by_name('enable_dnssec').first.checked diff --git a/plinth/modules/config/tests/config.feature b/plinth/modules/config/tests/config.feature index bdddf4e61..eb90d751c 100644 --- a/plinth/modules/config/tests/config.feature +++ b/plinth/modules/config/tests/config.feature @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -@system @essential @configuration +@system @essential @config Feature: Configuration Configure the system. @@ -21,4 +21,3 @@ Scenario: Change webserver home page And the home page is syncthing When I change the home page to plinth Then the home page should be plinth - diff --git a/plinth/modules/config/tests/test_functional.py b/plinth/modules/config/tests/test_functional.py index 84479a4f2..d1d79f74c 100644 --- a/plinth/modules/config/tests/test_functional.py +++ b/plinth/modules/config/tests/test_functional.py @@ -3,6 +3,75 @@ Functional, browser based tests for config app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('config.feature') + + +@given(parsers.parse('the home page is {app_name:w}')) +def set_home_page(session_browser, app_name): + _set_home_page(session_browser, app_name) + + +@when(parsers.parse('I change the hostname to {hostname:w}')) +def change_hostname_to(session_browser, hostname): + _set_hostname(session_browser, hostname) + + +@when(parsers.parse('I change the domain name to {domain:S}')) +def change_domain_name_to(session_browser, domain): + functional.set_domain_name(session_browser, domain) + + +@when(parsers.parse('I change the home page to {app_name:w}')) +def change_home_page_to(session_browser, app_name): + _set_home_page(session_browser, app_name) + + +@then(parsers.parse('the hostname should be {hostname:w}')) +def hostname_should_be(session_browser, hostname): + assert _get_hostname(session_browser) == hostname + + +@then(parsers.parse('the domain name should be {domain:S}')) +def domain_name_should_be(session_browser, domain): + assert _get_domain_name(session_browser) == domain + + +@then(parsers.parse('the home page should be {app_name:w}')) +def home_page_should_be(session_browser, app_name): + assert _check_home_page_redirect(session_browser, app_name) + + +def _get_hostname(browser): + functional.nav_to_module(browser, 'config') + return browser.find_by_id('id_hostname').value + + +def _set_hostname(browser, hostname): + functional.nav_to_module(browser, 'config') + browser.find_by_id('id_hostname').fill(hostname) + functional.submit(browser) + + +def _get_domain_name(browser): + functional.nav_to_module(browser, 'config') + return browser.find_by_id('id_domainname').value + + +def _set_home_page(browser, home_page): + if 'plinth' not in home_page and 'apache' not in home_page: + home_page = 'shortcut-' + home_page + + functional.nav_to_module(browser, 'config') + drop_down = browser.find_by_id('id_homepage') + drop_down.select(home_page) + functional.submit(browser) + + +def _check_home_page_redirect(browser, app_name): + functional.visit(browser, '/') + return browser.find_by_xpath( + "//a[contains(@href, '/plinth/') and @title='FreedomBox']") diff --git a/plinth/modules/coquelicot/tests/test_functional.py b/plinth/modules/coquelicot/tests/test_functional.py index b9eb4da65..e8c8a08fe 100644 --- a/plinth/modules/coquelicot/tests/test_functional.py +++ b/plinth/modules/coquelicot/tests/test_functional.py @@ -3,6 +3,124 @@ Functional, browser based tests for coquelicot app. """ -from pytest_bdd import scenarios +import random +import tempfile + +from pytest_bdd import given, parsers, scenarios, then, when +from selenium.webdriver.common.action_chains import ActionChains +from selenium.webdriver.common.keys import Keys + +from plinth.tests import functional scenarios('coquelicot.feature') + + +@given('a sample local file') +def sample_local_file(): + file_path, contents = _create_sample_local_file() + return dict(file_path=file_path, contents=contents) + + +@when(parsers.parse('I modify the maximum file size of coquelicot to {size:d}') + ) +def modify_max_file_size(session_browser, size): + _modify_max_file_size(session_browser, size) + + +@then(parsers.parse('the maximum file size of coquelicot should be {size:d}')) +def assert_max_file_size(session_browser, size): + assert _get_max_file_size(session_browser) == size + + +@when(parsers.parse('I modify the coquelicot upload password to {password:w}')) +def modify_upload_password(session_browser, password): + _modify_upload_password(session_browser, password) + + +@then( + parsers.parse( + 'I should be able to login to coquelicot with password {password:w}')) +def verify_upload_password(session_browser, password): + _verify_upload_password(session_browser, password) + + +@when( + parsers.parse('I upload the sample local file to coquelicot with password ' + '{password:w}')) +def coquelicot_upload_file(session_browser, sample_local_file, password): + url = _upload_file(session_browser, sample_local_file['file_path'], + password) + sample_local_file['upload_url'] = url + + +@when('I download the uploaded file from coquelicot') +def coquelicot_download_file(sample_local_file): + file_path = functional.download_file_outside_browser( + sample_local_file['upload_url']) + sample_local_file['download_path'] = file_path + + +@then('contents of downloaded sample file should be same as sample local file') +def coquelicot_compare_upload_download_files(sample_local_file): + _compare_files(sample_local_file['file_path'], + sample_local_file['download_path']) + + +def _create_sample_local_file(): + """Create a sample file for upload using browser.""" + contents = bytearray(random.getrandbits(8) for _ in range(64)) + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + temp_file.write(contents) + + return temp_file.name, contents + + +def _verify_upload_password(browser, password): + functional.visit(browser, '/coquelicot') + # ensure the password form is scrolled into view + browser.execute_script('window.scrollTo(100, 0)') + browser.find_by_id('upload_password').fill(password) + actions = ActionChains(browser.driver) + actions.send_keys(Keys.RETURN) + actions.perform() + assert functional.eventually(browser.is_element_present_by_css, + args=['div[style*="display: none;"]']) + + +def _upload_file(browser, file_path, password): + """Upload a local file from disk to coquelicot.""" + _verify_upload_password(browser, password) + browser.attach_file('file', file_path) + functional.submit(browser) + assert functional.eventually(browser.is_element_present_by_css, + args=['#content .url']) + url_textarea = browser.find_by_css('#content .url textarea').first + return url_textarea.value + + +def _modify_max_file_size(browser, size): + """Change the maximum file size of coquelicot to the given value""" + functional.visit(browser, '/plinth/apps/coquelicot/') + browser.find_by_id('id_max_file_size').fill(size) + functional.submit(browser, form_class='form-configuration') + + +def _get_max_file_size(browser): + """Get the maximum file size of coquelicot""" + functional.visit(browser, '/plinth/apps/coquelicot/') + return int(browser.find_by_id('id_max_file_size').value) + + +def _modify_upload_password(browser, password): + """Change the upload password for coquelicot to the given value""" + functional.visit(browser, '/plinth/apps/coquelicot/') + browser.find_by_id('id_upload_password').fill(password) + functional.submit(browser, form_class='form-configuration') + + +def _compare_files(file1, file2): + """Assert that the contents of two files are the same.""" + file1_contents = open(file1, 'rb').read() + file2_contents = open(file2, 'rb').read() + + assert file1_contents == file2_contents diff --git a/plinth/modules/datetime/tests/datetime.feature b/plinth/modules/datetime/tests/datetime.feature index e36b75f5e..718e51992 100644 --- a/plinth/modules/datetime/tests/datetime.feature +++ b/plinth/modules/datetime/tests/datetime.feature @@ -1,6 +1,6 @@ # SPDX-License-Identifier: AGPL-3.0-or-later -@essential @date_and_time @system +@essential @datetime @system Feature: Date and Time Configure time zone and network time service. @@ -8,16 +8,16 @@ Background: Given I'm a logged in user Scenario: Disable network time application - Given the network time application can be disabled - And the network time application is enabled - When I disable the network time application - Then the network time service should not be running + Given the datetime application can be disabled + And the datetime application is enabled + When I disable the datetime application + Then the datetime service should not be running Scenario: Enable network time application - Given the network time application can be disabled - And the network time application is disabled - When I enable the network time application - Then the network time service should be running + Given the datetime application can be disabled + And the datetime application is disabled + When I enable the datetime application + Then the datetime service should be running Scenario: Set timezone When I set the time zone to Africa/Abidjan diff --git a/plinth/modules/datetime/tests/test_functional.py b/plinth/modules/datetime/tests/test_functional.py index de8e87c77..8d4921f88 100644 --- a/plinth/modules/datetime/tests/test_functional.py +++ b/plinth/modules/datetime/tests/test_functional.py @@ -3,6 +3,31 @@ Functional, browser based tests for datetime app. """ -from pytest_bdd import scenarios +from pytest_bdd import parsers, scenarios, then, when + +from plinth.tests import functional scenarios('datetime.feature') + + +@when(parsers.parse('I set the time zone to {time_zone:S}')) +def time_zone_set(session_browser, time_zone): + _time_zone_set(session_browser, time_zone) + + +@then(parsers.parse('the time zone should be {time_zone:S}')) +def time_zone_assert(session_browser, time_zone): + assert time_zone == _time_zone_get(session_browser) + + +def _time_zone_set(browser, time_zone): + """Set the system time zone.""" + functional.nav_to_module(browser, 'datetime') + browser.select('time_zone', time_zone) + functional.submit(browser, form_class='form-configuration') + + +def _time_zone_get(browser): + """Set the system time zone.""" + functional.nav_to_module(browser, 'datetime') + return browser.find_by_name('time_zone').first.value diff --git a/plinth/tests/functional/data/sample.torrent b/plinth/modules/deluge/tests/data/sample.torrent similarity index 100% rename from plinth/tests/functional/data/sample.torrent rename to plinth/modules/deluge/tests/data/sample.torrent diff --git a/plinth/modules/deluge/tests/test_functional.py b/plinth/modules/deluge/tests/test_functional.py index 11f4c84e8..b2b8d88df 100644 --- a/plinth/modules/deluge/tests/test_functional.py +++ b/plinth/modules/deluge/tests/test_functional.py @@ -3,6 +3,172 @@ Functional, browser based tests for deluge app. """ -from pytest_bdd import scenarios +import os +import time + +from pytest_bdd import parsers, scenarios, then, when + +from plinth.tests import functional scenarios('deluge.feature') + + +@when('all torrents are removed from deluge') +def deluge_remove_all_torrents(session_browser): + _remove_all_torrents(session_browser) + + +@when('I upload a sample torrent to deluge') +def deluge_upload_sample_torrent(session_browser): + _upload_sample_torrent(session_browser) + + +@then( + parsers.parse( + 'there should be {torrents_number:d} torrents listed in deluge')) +def deluge_assert_number_of_torrents(session_browser, torrents_number): + assert torrents_number == _get_number_of_torrents(session_browser) + + +def _get_active_window_title(browser): + """Return the title of the currently active window in Deluge.""" + return browser.evaluate_script( + 'Ext.WindowMgr.getActive() ? Ext.WindowMgr.getActive().title : null') + + +def _ensure_logged_in(browser): + """Ensure that password dialog is answered and we can interact.""" + url = functional.base_url + '/deluge' + + def service_is_available(): + if browser.is_element_present_by_xpath( + '//h1[text()="Service Unavailable"]'): + functional.access_url(browser, 'deluge') + return False + + return True + + if browser.url != url: + browser.visit(url) + # After a backup restore, service may not be available immediately + functional.eventually(service_is_available) + + time.sleep(1) # Wait for Ext.js application in initialize + + if _get_active_window_title(browser) != 'Login': + return + + browser.find_by_id('_password').first.fill('deluge') + _click_active_window_button(browser, 'Login') + + assert functional.eventually( + lambda: _get_active_window_title(browser) != 'Login') + functional.eventually(browser.is_element_not_present_by_css, + args=['#add.x-item-disabled'], timeout=0.3) + + +def _open_connection_manager(browser): + """Open the connection manager dialog if not already open.""" + title = 'Connection Manager' + if _get_active_window_title(browser) == title: + return + + browser.find_by_css('button.x-deluge-connection-manager').first.click() + functional.eventually(lambda: _get_active_window_title(browser) == title) + + +def _ensure_connected(browser): + """Type the connection password if required and start Deluge daemon.""" + _ensure_logged_in(browser) + + # Change Default Password window appears once. + if _get_active_window_title(browser) == 'Change Default Password': + _click_active_window_button(browser, 'No') + + assert functional.eventually(browser.is_element_not_present_by_css, + args=['#add.x-item-disabled']) + + +def _remove_all_torrents(browser): + """Remove all torrents from deluge.""" + _ensure_connected(browser) + + while browser.find_by_css('#torrentGrid .torrent-name'): + browser.find_by_css('#torrentGrid .torrent-name').first.click() + + # Click remove toolbar button + browser.find_by_id('remove').first.click() + + # Remove window shows up + assert functional.eventually( + lambda: _get_active_window_title(browser) == 'Remove Torrent') + + _click_active_window_button(browser, 'Remove With Data') + + # Remove window disappears + assert functional.eventually( + lambda: not _get_active_window_title(browser)) + + +def _get_active_window_id(browser): + """Return the ID of the currently active window.""" + return browser.evaluate_script('Ext.WindowMgr.getActive().id') + + +def _click_active_window_button(browser, button_text): + """Click an action button in the active window.""" + browser.execute_script(''' + active_window = Ext.WindowMgr.getActive(); + active_window.buttons.forEach(function (button) {{ + if (button.text == "{button_text}") + button.btnEl.dom.click() + }})'''.format(button_text=button_text)) + + +def _upload_sample_torrent(browser): + """Upload a sample torrent into deluge.""" + _ensure_connected(browser) + + number_of_torrents = _get_number_of_torrents(browser) + + # Click add toolbar button + browser.find_by_id('add').first.click() + + # Add window appears + functional.eventually( + lambda: _get_active_window_title(browser) == 'Add Torrents') + + file_path = os.path.join(os.path.dirname(__file__), 'data', + 'sample.torrent') + + if browser.find_by_id('fileUploadForm'): # deluge-web 2.x + browser.attach_file('file', file_path) + else: # deluge-web 1.x + browser.find_by_css('button.x-deluge-add-file').first.click() + + # Add from file window appears + functional.eventually( + lambda: _get_active_window_title(browser) == 'Add from File') + + # Attach file + browser.attach_file('file', file_path) + + # Click Add + _click_active_window_button(browser, 'Add') + + functional.eventually( + lambda: _get_active_window_title(browser) == 'Add Torrents') + + # Click Add + time.sleep(1) + _click_active_window_button(browser, 'Add') + + functional.eventually( + lambda: _get_number_of_torrents(browser) > number_of_torrents) + + +def _get_number_of_torrents(browser): + """Return the number torrents currently in deluge.""" + _ensure_connected(browser) + + return len(browser.find_by_css('#torrentGrid .torrent-name')) diff --git a/plinth/modules/dynamicdns/tests/test_functional.py b/plinth/modules/dynamicdns/tests/test_functional.py index 5438d0c4e..276c487e6 100644 --- a/plinth/modules/dynamicdns/tests/test_functional.py +++ b/plinth/modules/dynamicdns/tests/test_functional.py @@ -3,6 +3,71 @@ Functional, browser based tests for dynamicdns app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, scenarios, then, when + +from plinth.tests import functional scenarios('dynamicdns.feature') + + +@given('dynamicdns is configured') +def dynamicdns_configure(session_browser): + _configure(session_browser) + + +@when('I change the dynamicdns configuration') +def dynamicdns_change_config(session_browser): + _change_config(session_browser) + + +@then('dynamicdns should have the original configuration') +def dynamicdns_has_original_config(session_browser): + assert _has_original_config(session_browser) + + +def _configure(browser): + functional.nav_to_module(browser, 'dynamicdns') + browser.find_link_by_href( + '/plinth/sys/dynamicdns/configure/').first.click() + browser.find_by_id('id_enabled').check() + browser.find_by_id('id_service_type').select('GnuDIP') + browser.find_by_id('id_dynamicdns_server').fill('example.com') + browser.find_by_id('id_dynamicdns_domain').fill('freedombox.example.com') + browser.find_by_id('id_dynamicdns_user').fill('tester') + browser.find_by_id('id_dynamicdns_secret').fill('testingtesting') + browser.find_by_id('id_dynamicdns_ipurl').fill( + 'http://myip.datasystems24.de') + functional.submit(browser) + + +def _has_original_config(browser): + functional.nav_to_module(browser, 'dynamicdns') + browser.find_link_by_href( + '/plinth/sys/dynamicdns/configure/').first.click() + enabled = browser.find_by_id('id_enabled').value + service_type = browser.find_by_id('id_service_type').value + server = browser.find_by_id('id_dynamicdns_server').value + domain = browser.find_by_id('id_dynamicdns_domain').value + user = browser.find_by_id('id_dynamicdns_user').value + ipurl = browser.find_by_id('id_dynamicdns_ipurl').value + if enabled and service_type == 'GnuDIP' and server == 'example.com' \ + and domain == 'freedombox.example.com' and user == 'tester' \ + and ipurl == 'http://myip.datasystems24.de': + return True + else: + return False + + +def _change_config(browser): + functional.nav_to_module(browser, 'dynamicdns') + browser.find_link_by_href( + '/plinth/sys/dynamicdns/configure/').first.click() + browser.find_by_id('id_enabled').check() + browser.find_by_id('id_service_type').select('GnuDIP') + browser.find_by_id('id_dynamicdns_server').fill('2.example.com') + browser.find_by_id('id_dynamicdns_domain').fill('freedombox2.example.com') + browser.find_by_id('id_dynamicdns_user').fill('tester2') + browser.find_by_id('id_dynamicdns_secret').fill('testingtesting2') + browser.find_by_id('id_dynamicdns_ipurl').fill( + 'http://myip2.datasystems24.de') + functional.submit(browser) diff --git a/plinth/modules/ejabberd/tests/test_functional.py b/plinth/modules/ejabberd/tests/test_functional.py index 5564dd8b7..7a2755019 100644 --- a/plinth/modules/ejabberd/tests/test_functional.py +++ b/plinth/modules/ejabberd/tests/test_functional.py @@ -3,6 +3,90 @@ Functional, browser based tests for ejabberd app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('ejabberd.feature') + + +@given('I have added a contact to my roster') +def ejabberd_add_contact(session_browser): + _jsxc_add_contact(session_browser) + + +@when('I delete the contact from my roster') +def ejabberd_delete_contact(session_browser): + _jsxc_delete_contact(session_browser) + + +@then('I should have a contact on my roster') +def ejabberd_should_have_contact(session_browser): + assert functional.eventually(_jsxc_has_contact, [session_browser]) + + +@when(parsers.parse('I enable message archive management')) +def ejabberd_enable_archive_management(session_browser): + _enable_message_archive_management(session_browser) + + +@when(parsers.parse('I disable message archive management')) +def ejabberd_disable_archive_management(session_browser): + _disable_message_archive_management(session_browser) + + +def _enable_message_archive_management(browser): + """Enable Message Archive Management in Ejabberd.""" + functional.nav_to_module(browser, 'ejabberd') + functional.change_checkbox_status(browser, 'ejabberd', 'id_MAM_enabled', + 'enabled') + + +def _disable_message_archive_management(browser): + """Enable Message Archive Management in Ejabberd.""" + functional.nav_to_module(browser, 'ejabberd') + functional.change_checkbox_status(browser, 'ejabberd', 'id_MAM_enabled', + 'disabled') + + +def _jsxc_login(browser): + """Login to JSXC.""" + username = functional.config['DEFAULT']['username'] + password = functional.config['DEFAULT']['password'] + functional.access_url(browser, 'jsxc') + browser.find_by_id('jsxc-username').fill(username) + browser.find_by_id('jsxc-password').fill(password) + browser.find_by_id('jsxc-submit').click() + relogin = browser.find_by_text('relogin') + if relogin: + relogin.first.click() + browser.find_by_id('jsxc_username').fill(username) + browser.find_by_id('jsxc_password').fill(password) + browser.find_by_text('Connect').first.click() + + +def _jsxc_add_contact(browser): + """Add a contact to JSXC user's roster.""" + functional.set_domain_name(browser, 'localhost') + functional.install(browser, 'jsxc') + _jsxc_login(browser) + new = browser.find_by_text('new contact') + if new: # roster is empty + new.first.click() + browser.find_by_id('jsxc_username').fill('alice@localhost') + browser.find_by_text('Add').first.click() + + +def _jsxc_delete_contact(browser): + """Delete the contact from JSXC user's roster.""" + _jsxc_login(browser) + browser.find_by_css('div.jsxc_more').first.click() + browser.find_by_text('delete contact').first.click() + browser.find_by_text('Remove').first.click() + + +def _jsxc_has_contact(browser): + """Check whether the contact is in JSXC user's roster.""" + _jsxc_login(browser) + contact = browser.find_by_text('alice@localhost') + return bool(contact) diff --git a/plinth/modules/gitweb/tests/test_functional.py b/plinth/modules/gitweb/tests/test_functional.py index df1f610c9..2dbbbaf8c 100644 --- a/plinth/modules/gitweb/tests/test_functional.py +++ b/plinth/modules/gitweb/tests/test_functional.py @@ -3,6 +3,311 @@ Functional, browser based tests for gitweb app. """ -from pytest_bdd import scenarios +import contextlib +import os +import shutil +import subprocess +import tempfile + +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('gitweb.feature') + +_default_url = functional.config['DEFAULT']['url'] + + +@given('a public repository') +@given('a repository') +@given('at least one repository exists') +def gitweb_repo(session_browser): + _create_repo(session_browser, 'Test-repo', 'public', True) + + +@given('a private repository') +def gitweb_private_repo(session_browser): + _create_repo(session_browser, 'Test-repo', 'private', True) + + +@given('both public and private repositories exist') +def gitweb_public_and_private_repo(session_browser): + _create_repo(session_browser, 'Test-repo', 'public', True) + _create_repo(session_browser, 'Test-repo2', 'private', True) + + +@given(parsers.parse("a {access:w} repository that doesn't exist")) +def gitweb_nonexistent_repo(session_browser, access): + _delete_repo(session_browser, 'Test-repo', ignore_missing=True) + return dict(access=access) + + +@given('all repositories are private') +def gitweb_all_repositories_private(session_browser): + _set_all_repos_private(session_browser) + + +@given(parsers.parse('a repository metadata:\n{metadata}')) +def gitweb_repo_metadata(session_browser, metadata): + metadata_dict = {} + for item in metadata.split('\n'): + item = item.split(': ') + metadata_dict[item[0]] = item[1] + return metadata_dict + + +@when('I create the repository') +def gitweb_create_repo(session_browser, access): + _create_repo(session_browser, 'Test-repo', access) + + +@when('I delete the repository') +def gitweb_delete_repo(session_browser): + _delete_repo(session_browser, 'Test-repo') + + +@when('I set the metadata of the repository') +def gitweb_edit_repo_metadata(session_browser, gitweb_repo_metadata): + _edit_repo_metadata(session_browser, 'Test-repo', gitweb_repo_metadata) + + +@when('using a git client') +def gitweb_using_git_client(): + pass + + +@then('the repository should be restored') +@then('the repository should be listed as a public') +def gitweb_repo_should_exists(session_browser): + assert _repo_exists(session_browser, 'Test-repo', access='public') + + +@then('the repository should be listed as a private') +def gitweb_private_repo_should_exists(session_browser): + assert _repo_exists(session_browser, 'Test-repo', 'private') + + +@then('the repository should not be listed') +def gitweb_repo_should_not_exist(session_browser, gitweb_repo): + assert not _repo_exists(session_browser, gitweb_repo) + + +@then('the public repository should be listed on gitweb') +@then('the repository should be listed on gitweb') +def gitweb_repo_should_exist_on_gitweb(session_browser): + assert _site_repo_exists(session_browser, 'Test-repo') + + +@then('the private repository should not be listed on gitweb') +def gitweb_private_repo_should_exists_on_gitweb(session_browser): + assert not _site_repo_exists(session_browser, 'Test-repo2') + + +@then('the metadata of the repository should be as set') +def gitweb_repo_metadata_should_match(session_browser, gitweb_repo_metadata): + actual_metadata = _get_repo_metadata(session_browser, 'Test-repo') + assert all(item in actual_metadata.items() + for item in gitweb_repo_metadata.items()) + + +@then('the repository should be publicly readable') +def gitweb_repo_publicly_readable(): + assert _repo_is_readable('Test-repo') + assert _repo_is_readable('Test-repo', url_git_extension=True) + + +@then('the repository should not be publicly readable') +def gitweb_repo_not_publicly_readable(): + assert not _repo_is_readable('Test-repo') + assert not _repo_is_readable('Test-repo', url_git_extension=True) + + +@then('the repository should not be publicly writable') +def gitweb_repo_not_publicly_writable(): + assert not _repo_is_writable('Test-repo') + assert not _repo_is_writable('Test-repo', url_git_extension=True) + + +@then('the repository should be privately readable') +def gitweb_repo_privately_readable(): + assert _repo_is_readable('Test-repo', with_auth=True) + assert _repo_is_readable('Test-repo', with_auth=True, + url_git_extension=True) + + +@then('the repository should be privately writable') +def gitweb_repo_privately_writable(): + assert _repo_is_writable('Test-repo', with_auth=True) + assert _repo_is_writable('Test-repo', with_auth=True, + url_git_extension=True) + + +def _create_repo(browser, repo, access=None, ok_if_exists=False): + """Create repository.""" + if not _repo_exists(browser, repo, access): + _delete_repo(browser, repo, ignore_missing=True) + browser.find_link_by_href('/plinth/apps/gitweb/create/').first.click() + browser.find_by_id('id_gitweb-name').fill(repo) + if access == 'private': + browser.find_by_id('id_gitweb-is_private').check() + elif access == 'public': + browser.find_by_id('id_gitweb-is_private').uncheck() + functional.submit(browser) + elif not ok_if_exists: + assert False, 'Repo already exists.' + + +def _delete_repo(browser, repo, ignore_missing=False): + """Delete repository.""" + functional.nav_to_module(browser, 'gitweb') + delete_link = browser.find_link_by_href( + '/plinth/apps/gitweb/{}/delete/'.format(repo)) + if delete_link or not ignore_missing: + delete_link.first.click() + functional.submit(browser) + + +def _edit_repo_metadata(browser, repo, metadata): + """Set repository metadata.""" + functional.nav_to_module(browser, 'gitweb') + browser.find_link_by_href( + '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click() + if 'name' in metadata: + browser.find_by_id('id_gitweb-name').fill(metadata['name']) + if 'description' in metadata: + browser.find_by_id('id_gitweb-description').fill( + metadata['description']) + if 'owner' in metadata: + browser.find_by_id('id_gitweb-owner').fill(metadata['owner']) + if 'access' in metadata: + if metadata['access'] == 'private': + browser.find_by_id('id_gitweb-is_private').check() + else: + browser.find_by_id('id_gitweb-is_private').uncheck() + functional.submit(browser) + + +def _get_repo_metadata(browser, repo): + """Get repository metadata.""" + functional.nav_to_module(browser, 'gitweb') + browser.find_link_by_href( + '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click() + metadata = {} + for item in ['name', 'description', 'owner']: + metadata[item] = browser.find_by_id('id_gitweb-' + item).value + if browser.find_by_id('id_gitweb-is_private').value: + metadata['access'] = 'private' + else: + metadata['access'] = 'public' + return metadata + + +def _get_repo_url(repo, with_auth): + """"Get repository URL""" + scheme = 'http' + if _default_url.startswith('https://'): + scheme = 'https' + url = _default_url.split( + '://')[1] if '://' in _default_url else _default_url + password = 'gitweb_wrong_password' + if with_auth: + password = functional.config['DEFAULT']['password'] + + return '{0}://{1}:{2}@{3}/gitweb/{4}'.format( + scheme, functional.config['DEFAULT']['username'], password, url, repo) + + +@contextlib.contextmanager +def _gitweb_temp_directory(): + """Create temporary directory""" + name = tempfile.mkdtemp(prefix='plinth_test_gitweb_') + yield name + shutil.rmtree(name) + + +def _gitweb_git_command_is_successful(command, cwd): + """Check if a command runs successfully or gives authentication error""" + process = subprocess.run(command, capture_output=True, cwd=cwd) + if process.returncode != 0: + if 'Authentication failed' in process.stderr.decode(): + return False + print(process.stdout.decode()) + # raise exception + process.check_returncode() + return True + + +def _repo_exists(browser, repo, access=None): + """Check whether the repository exists.""" + functional.nav_to_module(browser, 'gitweb') + links_found = browser.find_link_by_href('/gitweb/{}.git'.format(repo)) + access_matches = True + if links_found and access: + parent = links_found.first.find_by_xpath('..').first + private_icon = parent.find_by_css('.repo-private-icon') + if access == 'private': + access_matches = bool(private_icon) + if access == 'public': + access_matches = not bool(private_icon) + return bool(links_found) and access_matches + + +def _repo_is_readable(repo, with_auth=False, url_git_extension=False): + """Check if a git repo is readable with git client.""" + url = _get_repo_url(repo, with_auth) + if url_git_extension: + url = url + '.git' + git_command = ['git', 'clone', '-c', 'http.sslverify=false', url] + with _gitweb_temp_directory() as cwd: + return _gitweb_git_command_is_successful(git_command, cwd) + + +def _repo_is_writable(repo, with_auth=False, url_git_extension=False): + """Check if a git repo is writable with git client.""" + url = _get_repo_url(repo, with_auth) + if url_git_extension: + url = url + '.git' + + with _gitweb_temp_directory() as cwd: + subprocess.run(['mkdir', 'test-project'], check=True, cwd=cwd) + cwd = os.path.join(cwd, 'test-project') + prepare_git_repo_commands = [ + 'git init -q', 'git config http.sslVerify false', + 'git -c "user.name=Tester" -c "user.email=tester" ' + 'commit -q --allow-empty -m "test"' + ] + for command in prepare_git_repo_commands: + subprocess.run(command, shell=True, check=True, cwd=cwd) + git_push_command = ['git', 'push', '-qf', url, 'master'] + + return _gitweb_git_command_is_successful(git_push_command, cwd) + + +def _set_repo_access(browser, repo, access): + """Set repository as public or private.""" + functional.nav_to_module(browser, 'gitweb') + browser.find_link_by_href( + '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click() + if access == 'private': + browser.find_by_id('id_gitweb-is_private').check() + else: + browser.find_by_id('id_gitweb-is_private').uncheck() + functional.submit(browser) + + +def _set_all_repos_private(browser): + """Set all repositories private""" + functional.nav_to_module(browser, 'gitweb') + public_repos = [] + for element in browser.find_by_css('#gitweb-repo-list .list-group-item'): + if not element.find_by_css('.repo-private-icon'): + repo = element.find_by_css('.repo-label').first.text + public_repos.append(repo) + for repo in public_repos: + _set_repo_access(browser, repo, 'private') + + +def _site_repo_exists(browser, repo): + """Check whether the repository exists on Gitweb site.""" + browser.visit('{}/gitweb'.format(_default_url)) + return browser.find_by_css('a[href="/gitweb/{0}.git"]'.format(repo)) diff --git a/plinth/modules/help/tests/test_functional.py b/plinth/modules/help/tests/test_functional.py index e62284ce8..dcdc66b9d 100644 --- a/plinth/modules/help/tests/test_functional.py +++ b/plinth/modules/help/tests/test_functional.py @@ -3,6 +3,26 @@ Functional, browser based tests for help app. """ -from pytest_bdd import scenarios +from pytest_bdd import scenarios, then, when + +from plinth.tests import functional scenarios('help.feature') + + +@when('I go to the status logs page') +def help_go_to_status_logs(session_browser): + _go_to_status_logs(session_browser) + + +@then('status logs should be shown') +def help_status_logs_are_shown(session_browser): + assert _are_status_logs_shown(session_browser) + + +def _go_to_status_logs(browser): + functional.visit(browser, '/plinth/help/status-log/') + + +def _are_status_logs_shown(browser): + return browser.is_text_present('Logs begin') diff --git a/plinth/modules/ikiwiki/tests/ikiwiki.feature b/plinth/modules/ikiwiki/tests/ikiwiki.feature index 7b6ab085b..d70056851 100644 --- a/plinth/modules/ikiwiki/tests/ikiwiki.feature +++ b/plinth/modules/ikiwiki/tests/ikiwiki.feature @@ -6,23 +6,23 @@ Feature: ikiwiki Wiki and Blog Background: Given I'm a logged in user - Given the wiki application is installed + Given the ikiwiki application is installed -Scenario: Enable wiki application - Given the wiki application is disabled - When I enable the wiki application - Then the wiki site should be available +Scenario: Enable ikiwiki application + Given the ikiwiki application is disabled + When I enable the ikiwiki application + Then the ikiwiki site should be available @backups -Scenario: Backup and restore wiki - Given the wiki application is enabled +Scenario: Backup and restore ikiwiki + Given the ikiwiki application is enabled When there is an ikiwiki wiki And I create a backup of the ikiwiki app data with name test_ikiwiki And I delete the ikiwiki wiki And I restore the ikiwiki app data backup with name test_ikiwiki Then the ikiwiki wiki should be restored -Scenario: Disable wiki application - Given the wiki application is enabled - When I disable the wiki application - Then the wiki site should not be available +Scenario: Disable ikiwiki application + Given the ikiwiki application is enabled + When I disable the ikiwiki application + Then the ikiwiki site should not be available diff --git a/plinth/modules/ikiwiki/tests/test_functional.py b/plinth/modules/ikiwiki/tests/test_functional.py index c8a67665f..9f173973a 100644 --- a/plinth/modules/ikiwiki/tests/test_functional.py +++ b/plinth/modules/ikiwiki/tests/test_functional.py @@ -3,6 +3,52 @@ Functional, browser based tests for ikiwiki app. """ -from pytest_bdd import scenarios +from pytest_bdd import scenarios, then, when + +from plinth.tests import functional scenarios('ikiwiki.feature') + + +@when('there is an ikiwiki wiki') +def ikiwiki_create_wiki_if_needed(session_browser): + _create_wiki_if_needed(session_browser) + + +@when('I delete the ikiwiki wiki') +def ikiwiki_delete_wiki(session_browser): + _delete_wiki(session_browser) + + +@then('the ikiwiki wiki should be restored') +def ikiwiki_should_exist(session_browser): + assert _wiki_exists(session_browser) + + +def _create_wiki_if_needed(browser): + """Create wiki if it does not exist.""" + functional.nav_to_module(browser, 'ikiwiki') + wiki = browser.find_link_by_href('/ikiwiki/wiki') + if not wiki: + browser.find_link_by_href('/plinth/apps/ikiwiki/create/').first.click() + browser.find_by_id('id_ikiwiki-name').fill('wiki') + browser.find_by_id('id_ikiwiki-admin_name').fill( + functional.config['DEFAULT']['username']) + browser.find_by_id('id_ikiwiki-admin_password').fill( + functional.config['DEFAULT']['password']) + functional.submit(browser) + + +def _delete_wiki(browser): + """Delete wiki.""" + functional.nav_to_module(browser, 'ikiwiki') + browser.find_link_by_href( + '/plinth/apps/ikiwiki/wiki/delete/').first.click() + functional.submit(browser) + + +def _wiki_exists(browser): + """Check whether the wiki exists.""" + functional.nav_to_module(browser, 'ikiwiki') + wiki = browser.find_link_by_href('/ikiwiki/wiki') + return bool(wiki) diff --git a/plinth/modules/mediawiki/tests/test_functional.py b/plinth/modules/mediawiki/tests/test_functional.py index 62c41b3a5..626dfbd1b 100644 --- a/plinth/modules/mediawiki/tests/test_functional.py +++ b/plinth/modules/mediawiki/tests/test_functional.py @@ -3,6 +3,216 @@ Functional, browser based tests for mediawiki app. """ -from pytest_bdd import scenarios +import pathlib + +from pytest_bdd import parsers, scenarios, then, when + +from plinth.tests import functional scenarios('mediawiki.feature') + + +@when(parsers.parse('I enable mediawiki public registrations')) +def enable_mediawiki_public_registrations(session_browser): + _enable_public_registrations(session_browser) + + +@when(parsers.parse('I disable mediawiki public registrations')) +def disable_mediawiki_public_registrations(session_browser): + _disable_public_registrations(session_browser) + + +@when(parsers.parse('I enable mediawiki private mode')) +def enable_mediawiki_private_mode(session_browser): + _enable_private_mode(session_browser) + + +@when(parsers.parse('I disable mediawiki private mode')) +def disable_mediawiki_private_mode(session_browser): + _disable_private_mode(session_browser) + + +@when(parsers.parse('I set the mediawiki admin password to {password}')) +def set_mediawiki_admin_password(session_browser, password): + _set_admin_password(session_browser, password) + + +@then(parsers.parse('the mediawiki site should allow creating accounts')) +def mediawiki_allows_creating_accounts(session_browser): + _verify_create_account_link(session_browser) + + +@then(parsers.parse('the mediawiki site should not allow creating accounts')) +def mediawiki_does_not_allow_creating_accounts(session_browser): + _verify_no_create_account_link(session_browser) + + +@then( + parsers.parse('the mediawiki site should allow anonymous reads and writes') +) +def mediawiki_allows_anonymous_reads_edits(session_browser): + _verify_anonymous_reads_edits_link(session_browser) + + +@then( + parsers.parse( + 'the mediawiki site should not allow anonymous reads and writes')) +def mediawiki_does_not_allow__account_creation_anonymous_reads_edits( + session_browser): + _verify_no_anonymous_reads_edits_link(session_browser) + + +@then( + parsers.parse( + 'I should see the Upload File option in the side pane when logged in ' + 'with credentials {username:w} and {password:w}')) +def login_to_mediawiki_with_credentials(session_browser, username, password): + _login_with_credentials(session_browser, username, password) + + +@when('I delete the mediawiki main page') +def mediawiki_delete_main_page(session_browser): + _delete_main_page(session_browser) + + +@then('the mediawiki main page should be restored') +def mediawiki_verify_text(session_browser): + assert _has_main_page(session_browser) + + +@when( + parsers.parse( + 'I upload an image named {image:S} to mediawiki with credentials ' + '{username:w} and {password:w}')) +def upload_image(session_browser, username, password, image): + _upload_image(session_browser, username, password, image) + + +@then(parsers.parse('there should be {image:S} image')) +def uploaded_image_should_be_available(session_browser, image): + uploaded_image = _get_uploaded_image(session_browser, image) + assert image.lower() == uploaded_image.lower() + + +def _enable_public_registrations(browser): + """Enable public registrations in MediaWiki.""" + functional.nav_to_module(browser, 'mediawiki') + functional.change_checkbox_status(browser, 'mediawiki', + 'id_enable_public_registrations', + 'enabled') + + +def _disable_public_registrations(browser): + """Enable public registrations in MediaWiki.""" + functional.nav_to_module(browser, 'mediawiki') + functional.change_checkbox_status(browser, 'mediawiki', + 'id_enable_public_registrations', + 'disabled') + + +def _enable_private_mode(browser): + """Enable public registrations in MediaWiki.""" + functional.nav_to_module(browser, 'mediawiki') + functional.change_checkbox_status(browser, 'mediawiki', + 'id_enable_private_mode', 'enabled') + + +def _disable_private_mode(browser): + """Enable public registrations in MediaWiki.""" + functional.nav_to_module(browser, 'mediawiki') + functional.change_checkbox_status(browser, 'mediawiki', + 'id_enable_private_mode', 'disabled') + + +def _set_admin_password(browser, password): + """Set a password for the MediaWiki user called admin.""" + functional.nav_to_module(browser, 'mediawiki') + browser.find_by_id('id_password').fill(password) + functional.submit(browser, form_class='form-configuration') + + +def _verify_create_account_link(browser): + functional.visit(browser, '/mediawiki/index.php/Special:CreateAccount') + assert functional.eventually(browser.is_element_present_by_id, + args=['wpCreateaccount']) + + +def _verify_no_create_account_link(browser): + functional.visit(browser, '/mediawiki/index.php/Special:CreateAccount') + assert functional.eventually(browser.is_element_not_present_by_id, + args=['wpCreateaccount']) + + +def _verify_anonymous_reads_edits_link(browser): + functional.visit(browser, '/mediawiki') + assert functional.eventually(browser.is_element_present_by_id, + args=['ca-nstab-main']) + + +def _verify_no_anonymous_reads_edits_link(browser): + functional.visit(browser, '/mediawiki') + assert functional.eventually(browser.is_element_not_present_by_id, + args=['ca-nstab-main']) + assert functional.eventually(browser.is_element_present_by_id, + args=['ca-nstab-special']) + + +def _login(browser, username, password): + functional.visit(browser, '/mediawiki/index.php?title=Special:Login') + browser.find_by_id('wpName1').fill(username) + browser.find_by_id('wpPassword1').fill(password) + with functional.wait_for_page_update(browser): + browser.find_by_id('wpLoginAttempt').click() + + +def _login_with_credentials(browser, username, password): + _login(browser, username, password) + # Had to put it in the same step because sessions don't + # persist between steps + assert functional.eventually(browser.is_element_present_by_id, + args=['t-upload']) + + +def _upload_image(browser, username, password, image): + """Upload an image to MediaWiki. Idempotent.""" + functional.visit(browser, '/mediawiki') + _login(browser, username, password) + + # Upload file + functional.visit(browser, '/mediawiki/Special:Upload') + file_path = pathlib.Path(__file__).parent + file_path /= '../../../../static/themes/default/img/' + image + browser.attach_file('wpUploadFile', str(file_path.resolve())) + functional.submit(browser, element=browser.find_by_name('wpUpload')[0]) + + +def _get_number_of_uploaded_images(browser): + functional.visit(browser, '/mediawiki/Special:ListFiles') + return len(browser.find_by_css('.TablePager_col_img_timestamp')) + + +def _get_uploaded_image(browser, image): + functional.visit(browser, '/mediawiki/Special:ListFiles') + elements = browser.find_link_by_partial_href(image) + return elements[0].value + + +def _delete_main_page(browser): + """Delete the mediawiki main page.""" + _login(browser, 'admin', 'whatever123') + functional.visit(browser, + '/mediawiki/index.php?title=Main_Page&action=delete') + with functional.wait_for_page_update(browser): + browser.find_by_id('wpConfirmB').first.click() + + +def _has_main_page(browser): + """Check if mediawiki main page exists.""" + return functional.eventually(__has_main_page, [browser]) + + +def __has_main_page(browser): + """Check if mediawiki main page exists.""" + functional.visit(browser, '/mediawiki/Main_Page') + content = browser.find_by_id('mw-content-text').first + return 'This page has been deleted.' not in content.text diff --git a/plinth/modules/mldonkey/tests/test_functional.py b/plinth/modules/mldonkey/tests/test_functional.py index 4bc0d706d..5a53cc855 100644 --- a/plinth/modules/mldonkey/tests/test_functional.py +++ b/plinth/modules/mldonkey/tests/test_functional.py @@ -3,6 +3,60 @@ Functional, browser based tests for mldonkey app. """ -from pytest_bdd import scenarios +from pytest_bdd import parsers, scenarios, then, when + +from plinth.tests import functional scenarios('mldonkey.feature') + + +@when('all ed2k files are removed from mldonkey') +def mldonkey_remove_all_ed2k_files(session_browser): + _remove_all_ed2k_files(session_browser) + + +@when('I upload a sample ed2k file to mldonkey') +def mldonkey_upload_sample_ed2k_file(session_browser): + _upload_sample_ed2k_file(session_browser) + + +@then( + parsers.parse( + 'there should be {ed2k_files_number:d} ed2k files listed in mldonkey')) +def mldonkey_assert_number_of_ed2k_files(session_browser, ed2k_files_number): + assert ed2k_files_number == _get_number_of_ed2k_files(session_browser) + + +def _submit_command(browser, command): + """Submit a command to mldonkey.""" + with browser.get_iframe('commands') as commands_frame: + commands_frame.find_by_css('.txt2').fill(command) + commands_frame.find_by_css('.but2').click() + + +def _remove_all_ed2k_files(browser): + """Remove all ed2k files from mldonkey.""" + functional.visit(browser, '/mldonkey/') + _submit_command(browser, 'cancel all') + _submit_command(browser, 'confirm yes') + + +def _upload_sample_ed2k_file(browser): + """Upload a sample ed2k file into mldonkey.""" + functional.visit(browser, '/mldonkey/') + dllink_command = 'dllink ed2k://|file|foo.bar|123|' \ + '0123456789ABCDEF0123456789ABCDEF|/' + _submit_command(browser, dllink_command) + + +def _get_number_of_ed2k_files(browser): + """Return the number of ed2k files currently in mldonkey.""" + functional.visit(browser, '/mldonkey/') + + with browser.get_iframe('commands') as commands_frame: + commands_frame.find_by_xpath( + '//tr//td[contains(text(), "Transfers")]').click() + + with browser.get_iframe('output') as output_frame: + return len(output_frame.find_by_css('.dl-1')) + len( + output_frame.find_by_css('.dl-2')) diff --git a/plinth/modules/monkeysphere/tests/test_functional.py b/plinth/modules/monkeysphere/tests/test_functional.py index f475b9320..45ab8383d 100644 --- a/plinth/modules/monkeysphere/tests/test_functional.py +++ b/plinth/modules/monkeysphere/tests/test_functional.py @@ -3,6 +3,79 @@ Functional, browser based tests for monkeysphere app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('monkeysphere.feature') + + +@given( + parsers.parse( + 'the {key_type:w} key for {domain:S} is imported in monkeysphere')) +def monkeysphere_given_import_key(session_browser, key_type, domain): + _import_key(session_browser, key_type.lower(), domain) + + +@when(parsers.parse('I import {key_type:w} key for {domain:S} in monkeysphere') + ) +def monkeysphere_import_key(session_browser, key_type, domain): + _import_key(session_browser, key_type.lower(), domain) + + +@then( + parsers.parse( + 'the {key_type:w} key should imported for {domain:S} in monkeysphere')) +def monkeysphere_assert_imported_key(session_browser, key_type, domain): + _assert_imported_key(session_browser, key_type.lower(), domain) + + +@then( + parsers.parse('I should be able to publish {key_type:w} key for ' + '{domain:S} in monkeysphere')) +def monkeysphere_publish_key(session_browser, key_type, domain): + _publish_key(session_browser, key_type.lower(), domain) + + +def _find_domain(browser, key_type, domain_type, domain): + """Iterate every domain of a given type which given key type.""" + keys_of_type = browser.find_by_css( + '.monkeysphere-service-{}'.format(key_type)) + for key_of_type in keys_of_type: + search_domains = key_of_type.find_by_css( + '.monkeysphere-{}-domain'.format(domain_type)) + for search_domain in search_domains: + if search_domain.text == domain: + return key_of_type, search_domain + + raise IndexError('Domain not found') + + +def _import_key(browser, key_type, domain): + """Import a key of specified type for given domain into monkeysphere.""" + try: + monkeysphere_assert_imported_key(browser, key_type, domain) + except IndexError: + pass + else: + return + + key, _ = _find_domain(browser, key_type, 'importable', domain) + with functional.wait_for_page_update(browser): + key.find_by_css('.button-import').click() + + +def _assert_imported_key(browser, key_type, domain): + """Assert that a key of specified type for given domain was imported..""" + functional.nav_to_module(browser, 'monkeysphere') + return _find_domain(browser, key_type, 'imported', domain) + + +def _publish_key(browser, key_type, domain): + """Publish a key of specified type for given domain from monkeysphere.""" + functional.nav_to_module(browser, 'monkeysphere') + key, _ = _find_domain(browser, key_type, 'imported', domain) + with functional.wait_for_page_update(browser): + key.find_by_css('.button-publish').click() + + functional.wait_for_config_update(browser, 'monkeysphere') diff --git a/plinth/modules/openvpn/tests/test_functional.py b/plinth/modules/openvpn/tests/test_functional.py index 176632bec..2c5fa58d8 100644 --- a/plinth/modules/openvpn/tests/test_functional.py +++ b/plinth/modules/openvpn/tests/test_functional.py @@ -3,6 +3,44 @@ Functional, browser based tests for openvpn app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then + +from plinth.tests import functional scenarios('openvpn.feature') + + +@given(parsers.parse('the openvpn application is setup')) +def openvpn_setup(session_browser): + """Setup the OpenVPN application after installation.""" + functional.nav_to_module(session_browser, 'openvpn') + setup_form = session_browser.find_by_css('.form-setup') + if not setup_form: + return + + functional.submit(session_browser, form_class='form-setup') + functional.wait_for_config_update(session_browser, 'openvpn') + + +@given('I download openvpn profile') +def openvpn_download_profile(session_browser): + return _download_profile(session_browser) + + +@then('the openvpn profile should be downloadable') +def openvpn_profile_downloadable(session_browser): + _download_profile(session_browser) + + +@then('the openvpn profile downloaded should be same as before') +def openvpn_profile_download_compare(session_browser, + openvpn_download_profile): + new_profile = _download_profile(session_browser) + assert openvpn_download_profile == new_profile + + +def _download_profile(browser): + """Download the current user's profile into a file and return path.""" + functional.nav_to_module(browser, 'openvpn') + url = browser.find_by_css('.form-profile')['action'] + return functional.download_file(browser, url) diff --git a/plinth/modules/pagekite/tests/test_functional.py b/plinth/modules/pagekite/tests/test_functional.py index 227edad44..d18168619 100644 --- a/plinth/modules/pagekite/tests/test_functional.py +++ b/plinth/modules/pagekite/tests/test_functional.py @@ -3,6 +3,45 @@ Functional, browser based tests for pagekite app. """ -from pytest_bdd import scenarios +from pytest_bdd import parsers, scenarios, then, when + +from plinth.tests import functional scenarios('pagekite.feature') + + +@when( + parsers.parse('I configure pagekite with host {host:S}, port {port:d}, ' + 'kite name {kite_name:S} and kite secret {kite_secret:w}')) +def pagekite_configure(session_browser, host, port, kite_name, kite_secret): + _configure(session_browser, host, port, kite_name, kite_secret) + + +@then( + parsers.parse( + 'pagekite should be configured with host {host:S}, port {port:d}, ' + 'kite name {kite_name:S} and kite secret {kite_secret:w}')) +def pagekite_assert_configured(session_browser, host, port, kite_name, + kite_secret): + assert (host, port, kite_name, + kite_secret) == _get_configuration(session_browser) + + +def _configure(browser, host, port, kite_name, kite_secret): + """Configure pagekite basic parameters.""" + functional.nav_to_module(browser, 'pagekite') + # time.sleep(0.250) # Wait for 200ms show animation to complete + browser.fill('pagekite-server_domain', host) + browser.fill('pagekite-server_port', str(port)) + browser.fill('pagekite-kite_name', kite_name) + browser.fill('pagekite-kite_secret', kite_secret) + functional.submit(browser, form_class='form-configuration') + + +def _get_configuration(browser): + """Return pagekite basic parameters.""" + functional.nav_to_module(browser, 'pagekite') + return (browser.find_by_name('pagekite-server_domain').value, + int(browser.find_by_name('pagekite-server_port').value), + browser.find_by_name('pagekite-kite_name').value, + browser.find_by_name('pagekite-kite_secret').value) diff --git a/plinth/modules/radicale/tests/test_functional.py b/plinth/modules/radicale/tests/test_functional.py index 5af778039..7b3a29463 100644 --- a/plinth/modules/radicale/tests/test_functional.py +++ b/plinth/modules/radicale/tests/test_functional.py @@ -3,6 +3,118 @@ Functional, browser based tests for radicale app. """ -from pytest_bdd import scenarios +import logging +import requests +from pytest_bdd import given, scenarios, then, when + +from plinth.tests import functional + +logger = logging.getLogger(__name__) scenarios('radicale.feature') + + +@given('the access rights are set to "only the owner can view or make changes"' + ) +def radicale_given_owner_only(session_browser): + _set_access_rights(session_browser, 'owner_only') + + +@given('the access rights are set to "any user can view, but only the ' + 'owner can make changes"') +def radicale_given_owner_write(session_browser): + _set_access_rights(session_browser, 'owner_write') + + +@given('the access rights are set to "any user can view or make changes"') +def radicale_given_authenticated(session_browser): + _set_access_rights(session_browser, 'authenticated') + + +@when('I change the access rights to "only the owner can view or make changes"' + ) +def radicale_set_owner_only(session_browser): + _set_access_rights(session_browser, 'owner_only') + + +@when('I change the access rights to "any user can view, but only the ' + 'owner can make changes"') +def radicale_set_owner_write(session_browser): + _set_access_rights(session_browser, 'owner_write') + + +@when('I change the access rights to "any user can view or make changes"') +def radicale_set_authenticated(session_browser): + _set_access_rights(session_browser, 'authenticated') + + +@then('the access rights should be "only the owner can view or make changes"') +def radicale_check_owner_only(session_browser): + assert _get_access_rights(session_browser) == 'owner_only' + + +@then('the access rights should be "any user can view, but only the ' + 'owner can make changes"') +def radicale_check_owner_write(session_browser): + assert _get_access_rights(session_browser) == 'owner_write' + + +@then('the access rights should be "any user can view or make changes"') +def radicale_check_authenticated(session_browser): + assert _get_access_rights(session_browser) == 'authenticated' + + +@then('the calendar should be available') +def assert_calendar_is_available(session_browser): + assert _calendar_is_available(session_browser) + + +@then('the calendar should not be available') +def assert_calendar_is_not_available(session_browser): + assert not _calendar_is_available(session_browser) + + +@then('the addressbook should be available') +def assert_addressbook_is_available(session_browser): + assert _addressbook_is_available(session_browser) + + +@then('the addressbook should not be available') +def assert_addressbook_is_not_available(session_browser): + assert not _addressbook_is_available(session_browser) + + +def _get_access_rights(browser): + access_rights_types = ['owner_only', 'owner_write', 'authenticated'] + functional.nav_to_module(browser, 'radicale') + for access_rights_type in access_rights_types: + if browser.find_by_value(access_rights_type).checked: + return access_rights_type + + +def _set_access_rights(browser, access_rights_type): + functional.nav_to_module(browser, 'radicale') + browser.choose('access_rights', access_rights_type) + functional.submit(browser, form_class='form-configuration') + + +def _calendar_is_available(browser): + """Return whether calendar is available at well-known URL.""" + conf = functional.config['DEFAULT'] + url = functional.base_url + '/.well-known/caldav' + logging.captureWarnings(True) + request = requests.get(url, auth=(conf['username'], conf['password']), + verify=False) + logging.captureWarnings(False) + return request.status_code != 404 + + +def _addressbook_is_available(browser): + """Return whether addressbook is available at well-known URL.""" + conf = functional.config['DEFAULT'] + url = functional.base_url + '/.well-known/carddav' + logging.captureWarnings(True) + request = requests.get(url, auth=(conf['username'], conf['password']), + verify=False) + logging.captureWarnings(False) + return request.status_code != 404 diff --git a/plinth/modules/samba/tests/test_functional.py b/plinth/modules/samba/tests/test_functional.py index 237a1171c..5b2319fb0 100644 --- a/plinth/modules/samba/tests/test_functional.py +++ b/plinth/modules/samba/tests/test_functional.py @@ -3,6 +3,117 @@ Functional, browser based tests for samba app. """ -from pytest_bdd import scenarios +import random +import string +import subprocess +import urllib + +from pytest_bdd import parsers, scenarios, then, when + +from plinth.tests import functional scenarios('samba.feature') + + +@when(parsers.parse('I {task:w} the {share_type:w} samba share')) +def samba_enable_share(session_browser, task, share_type): + if task == 'enable': + _set_share(session_browser, share_type, status='enabled') + elif task == 'disable': + _set_share(session_browser, share_type, status='disabled') + + +@then(parsers.parse('I can write to the {share_type:w} samba share')) +def samba_share_should_be_writable(share_type): + _assert_share_is_writable(share_type) + + +@then(parsers.parse('a guest user can write to the {share_type:w} samba share') + ) +def samba_share_should_be_writable_to_guest(share_type): + _assert_share_is_writable(share_type, as_guest=True) + + +@then( + parsers.parse('a guest user can\'t access the {share_type:w} samba share')) +def samba_share_should_not_be_accessible_to_guest(share_type): + _assert_share_is_not_accessible(share_type, as_guest=True) + + +@then(parsers.parse('the {share_type:w} samba share should not be available')) +def samba_share_should_not_be_available(share_type): + _assert_share_is_not_available(share_type) + + +def _set_share(browser, share_type, status='enabled'): + """Enable or disable samba share.""" + disk_name = 'disk' + share_type_name = '{0}_share'.format(share_type) + functional.nav_to_module(browser, 'samba') + for elem in browser.find_by_tag('td'): + if elem.text == disk_name: + share_form = elem.find_by_xpath('(..//*)[2]/form').first + share_btn = share_form.find_by_name(share_type_name).first + if status == 'enabled' and share_btn['value'] == 'enable': + share_btn.click() + elif status == 'disabled' and share_btn['value'] == 'disable': + share_btn.click() + break + + +def _write_to_share(share_type, as_guest=False): + """Write to the samba share, return output messages as string.""" + disk_name = 'disk' + default_url = functional.config['DEFAULT']['url'] + if share_type == 'open': + share_name = disk_name + else: + share_name = '{0}_{1}'.format(disk_name, share_type) + hostname = urllib.parse.urlparse(default_url).hostname + servicename = '\\\\{0}\\{1}'.format(hostname, share_name) + directory = '_plinth-test_{0}'.format(''.join( + random.SystemRandom().choices(string.ascii_letters, k=8))) + port = functional.config['DEFAULT']['samba_port'] + + smb_command = ['smbclient', '-W', 'WORKGROUP', '-p', port] + if as_guest: + smb_command += ['-N'] + else: + smb_command += [ + '-U', '{0}%{1}'.format(functional.config['DEFAULT']['username'], + functional.config['DEFAULT']['password']) + ] + smb_command += [ + servicename, '-c', 'mkdir {0}; rmdir {0}'.format(directory) + ] + + return subprocess.check_output(smb_command).decode() + + +def _assert_share_is_writable(share_type, as_guest=False): + """Assert that samba share is writable.""" + output = _write_to_share(share_type, as_guest=False) + + assert not output, output + + +def _assert_share_is_not_accessible(share_type, as_guest=False): + """Assert that samba share is not accessible.""" + try: + _write_to_share(share_type, as_guest) + except subprocess.CalledProcessError as err: + err_output = err.output.decode() + assert 'NT_STATUS_ACCESS_DENIED' in err_output, err_output + else: + assert False, 'Can access the share.' + + +def _assert_share_is_not_available(share_type): + """Assert that samba share is not accessible.""" + try: + _write_to_share(share_type) + except subprocess.CalledProcessError as err: + err_output = err.output.decode() + assert 'NT_STATUS_BAD_NETWORK_NAME' in err_output, err_output + else: + assert False, 'Can access the share.' diff --git a/plinth/modules/searx/tests/test_functional.py b/plinth/modules/searx/tests/test_functional.py index 459de6735..e24ca2548 100644 --- a/plinth/modules/searx/tests/test_functional.py +++ b/plinth/modules/searx/tests/test_functional.py @@ -3,6 +3,37 @@ Functional, browser based tests for searx app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, scenarios, when + +from plinth.tests import functional scenarios('searx.feature') + + +@given('public access is enabled in searx') +def searx_public_access_enabled(session_browser): + _enable_public_access(session_browser) + + +@when('I enable public access in searx') +def searx_enable_public_access(session_browser): + _enable_public_access(session_browser) + + +@when('I disable public access in searx') +def searx_disable_public_access(session_browser): + _disable_public_access(session_browser) + + +def _enable_public_access(browser): + """Enable Public Access in SearX""" + functional.nav_to_module(browser, 'searx') + browser.find_by_id('id_public_access').check() + functional.submit(browser, form_class='form-configuration') + + +def _disable_public_access(browser): + """Enable Public Access in SearX""" + functional.nav_to_module(browser, 'searx') + browser.find_by_id('id_public_access').uncheck() + functional.submit(browser, form_class='form-configuration') diff --git a/plinth/modules/security/tests/test_functional.py b/plinth/modules/security/tests/test_functional.py index af98e32d5..c0266b111 100644 --- a/plinth/modules/security/tests/test_functional.py +++ b/plinth/modules/security/tests/test_functional.py @@ -3,6 +3,43 @@ Functional, browser based tests for security app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('security.feature') + + +@given(parsers.parse('restricted console logins are {enabled}')) +def security_given_enable_restricted_logins(session_browser, enabled): + should_enable = (enabled == 'enabled') + _enable_restricted_logins(session_browser, should_enable) + + +@when(parsers.parse('I {enable} restricted console logins')) +def security_enable_restricted_logins(session_browser, enable): + should_enable = (enable == 'enable') + _enable_restricted_logins(session_browser, should_enable) + + +@then(parsers.parse('restricted console logins should be {enabled}')) +def security_assert_restricted_logins(session_browser, enabled): + enabled = (enabled == 'enabled') + assert _get_restricted_logins(session_browser) == enabled + + +def _enable_restricted_logins(browser, should_enable): + """Enable/disable restricted logins in security module.""" + functional.nav_to_module(browser, 'security') + if should_enable: + browser.check('security-restricted_access') + else: + browser.uncheck('security-restricted_access') + + functional.submit(browser) + + +def _get_restricted_logins(browser): + """Return whether restricted console logins is enabled.""" + functional.nav_to_module(browser, 'security') + return browser.find_by_name('security-restricted_access').first.checked diff --git a/plinth/modules/shadowsocks/tests/test_functional.py b/plinth/modules/shadowsocks/tests/test_functional.py index 3ee9d1237..3e6095690 100644 --- a/plinth/modules/shadowsocks/tests/test_functional.py +++ b/plinth/modules/shadowsocks/tests/test_functional.py @@ -3,6 +3,43 @@ Functional, browser based tests for shadowsocks app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('shadowsocks.feature') + + +@given('the shadowsocks application is configured') +def configure_shadowsocks(session_browser): + _configure(session_browser, 'example.com', 'fakepassword') + + +@when( + parsers.parse('I configure shadowsocks with server {server:S} and ' + 'password {password:w}')) +def configure_shadowsocks_with_details(session_browser, server, password): + _configure(session_browser, server, password) + + +@then( + parsers.parse('shadowsocks should be configured with server {server:S} ' + 'and password {password:w}')) +def assert_shadowsocks_configuration(session_browser, server, password): + assert (server, password) == _get_configuration(session_browser) + + +def _configure(browser, server, password): + """Configure shadowsocks client with given server details.""" + functional.visit(browser, '/plinth/apps/shadowsocks/') + browser.find_by_id('id_server').fill(server) + browser.find_by_id('id_password').fill(password) + functional.submit(browser, form_class='form-configuration') + + +def _get_configuration(browser): + """Return the server and password currently configured in shadowsocks.""" + functional.visit(browser, '/plinth/apps/shadowsocks/') + server = browser.find_by_id('id_server').value + password = browser.find_by_id('id_password').value + return server, password diff --git a/plinth/modules/sharing/tests/test_functional.py b/plinth/modules/sharing/tests/test_functional.py index 974840cec..7ce837e68 100644 --- a/plinth/modules/sharing/tests/test_functional.py +++ b/plinth/modules/sharing/tests/test_functional.py @@ -3,6 +3,145 @@ Functional, browser based tests for sharing app. """ -from pytest_bdd import scenarios +import pytest +import splinter +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('sharing.feature') + + +@given(parsers.parse('share {name:w} is not available')) +def remove_share(session_browser, name): + _remove_share(session_browser, name) + + +@when(parsers.parse('I add a share {name:w} from path {path} for {group:w}')) +def add_share(session_browser, name, path, group): + _add_share(session_browser, name, path, group) + + +@when( + parsers.parse('I edit share {old_name:w} to {new_name:w} from path {path} ' + 'for {group:w}')) +def edit_share(session_browser, old_name, new_name, path, group): + _edit_share(session_browser, old_name, new_name, path, group) + + +@when(parsers.parse('I remove share {name:w}')) +def remove_share2(session_browser, name): + _remove_share(session_browser, name) + + +@when(parsers.parse('I edit share {name:w} to be public')) +def edit_share_public_access(session_browser, name): + _make_share_public(session_browser, name) + + +@then( + parsers.parse( + 'the share {name:w} should be listed from path {path} for {group:w}')) +def verify_share(session_browser, name, path, group): + _verify_share(session_browser, name, path, group) + + +@then(parsers.parse('the share {name:w} should not be listed')) +def verify_invalid_share(session_browser, name): + with pytest.raises(splinter.exceptions.ElementDoesNotExist): + _get_share(session_browser, name) + + +@then(parsers.parse('the share {name:w} should be accessible')) +def access_share(session_browser, name): + _access_share(session_browser, name) + + +@then(parsers.parse('the share {name:w} should not exist')) +def verify_nonexistant_share(session_browser, name): + _verify_nonexistant_share(session_browser, name) + + +@then(parsers.parse('the share {name:w} should not be accessible')) +def verify_inaccessible_share(session_browser, name): + _verify_inaccessible_share(session_browser, name) + + +def _remove_share(browser, name): + """Remove a share in sharing app.""" + try: + share_row = _get_share(browser, name) + except splinter.exceptions.ElementDoesNotExist: + pass + else: + share_row.find_by_css('.share-remove')[0].click() + + +def _add_share(browser, name, path, group): + """Add a share in sharing app.""" + functional.visit(browser, '/plinth/apps/sharing/add/') + browser.fill('sharing-name', name) + browser.fill('sharing-path', path) + browser.find_by_css( + '#id_sharing-groups input[value="{}"]'.format(group)).check() + functional.submit(browser) + + +def _edit_share(browser, old_name, new_name, path, group): + """Edit a share in sharing app.""" + row = _get_share(browser, old_name) + with functional.wait_for_page_update(browser): + row.find_by_css('.share-edit')[0].click() + browser.fill('sharing-name', new_name) + browser.fill('sharing-path', path) + browser.find_by_css('#id_sharing-groups input').uncheck() + browser.find_by_css( + '#id_sharing-groups input[value="{}"]'.format(group)).check() + functional.submit(browser) + + +def _get_share(browser, name): + """Return the row for a given share.""" + functional.visit(browser, '/plinth/apps/sharing/') + return browser.find_by_id('share-{}'.format(name))[0] + + +def _verify_share(browser, name, path, group): + """Verfiy that a share exists in list of shares.""" + href = f'{functional.base_url}/share/{name}' + url = f'/share/{name}' + row = _get_share(browser, name) + assert row.find_by_css('.share-name')[0].text == name + assert row.find_by_css('.share-path')[0].text == path + assert row.find_by_css('.share-url a')[0]['href'] == href + assert row.find_by_css('.share-url a')[0].text == url + assert row.find_by_css('.share-groups')[0].text == group + + +def _access_share(browser, name): + """Visit a share and see if it is accessible.""" + row = _get_share(browser, name) + url = row.find_by_css('.share-url a')[0]['href'] + browser.visit(url) + assert '/share/{}'.format(name) in browser.title + + +def _make_share_public(browser, name): + """Make share publicly accessible.""" + row = _get_share(browser, name) + with functional.wait_for_page_update(browser): + row.find_by_css('.share-edit')[0].click() + browser.find_by_id('id_sharing-is_public').check() + functional.submit(browser) + + +def _verify_nonexistant_share(browser, name): + """Verify that given URL for a given share name is a 404.""" + functional.visit(browser, f'/share/{name}') + assert '404' in browser.title + + +def _verify_inaccessible_share(browser, name): + """Verify that given URL for a given share name denies permission.""" + functional.visit(browser, f'/share/{name}') + functional.eventually(lambda: '/plinth' in browser.url, args=[]) diff --git a/plinth/modules/snapshot/tests/test_functional.py b/plinth/modules/snapshot/tests/test_functional.py index 4fc5aee8d..0eecf3c53 100644 --- a/plinth/modules/snapshot/tests/test_functional.py +++ b/plinth/modules/snapshot/tests/test_functional.py @@ -3,6 +3,125 @@ Functional, browser based tests for snapshot app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('snapshot.feature') + + +@given('the list of snapshots is empty') +def empty_snapshots_list(session_browser): + _delete_all(session_browser) + + +@when('I manually create a snapshot') +def create_snapshot(session_browser): + _create(session_browser) + + +@then(parsers.parse('there should be {count:d} snapshot in the list')) +def verify_snapshot_count(session_browser, count): + num_snapshots = _get_count(session_browser) + assert num_snapshots == count + + +@given( + parsers.parse( + 'snapshots are configured with free space {free_space:d}, timeline ' + 'snapshots {timeline_enabled:w}, software snapshots ' + '{software_enabled:w}, hourly limit {hourly:d}, daily limit {daily:d}' + ', weekly limit {weekly:d}, monthly limit {monthly:d}, yearly limit ' + '{yearly:d}')) +def snapshot_given_set_configuration(session_browser, free_space, + timeline_enabled, software_enabled, + hourly, daily, weekly, monthly, yearly): + timeline_enabled = (timeline_enabled == 'enabled') + software_enabled = (software_enabled == 'enabled') + _set_configuration(session_browser, free_space, timeline_enabled, + software_enabled, hourly, daily, weekly, monthly, + yearly) + + +@when( + parsers.parse( + 'I configure snapshots with free space {free_space:d}, ' + 'timeline snapshots {timeline_enabled:w}, ' + 'software snapshots {software_enabled:w}, hourly limit {hourly:d}, ' + 'daily limit {daily:d}, weekly limit {weekly:d}, monthly limit ' + '{monthly:d}, yearly limit {yearly:d}')) +def snapshot_set_configuration(session_browser, free_space, timeline_enabled, + software_enabled, hourly, daily, weekly, + monthly, yearly): + timeline_enabled = (timeline_enabled == 'enabled') + software_enabled = (software_enabled == 'enabled') + _set_configuration(session_browser, free_space, timeline_enabled, + software_enabled, hourly, daily, weekly, monthly, + yearly) + + +@then( + parsers.parse( + 'snapshots should be configured with free space {free_space:d}, ' + 'timeline snapshots {timeline_enabled:w}, software snapshots ' + '{software_enabled:w}, hourly limit {hourly:d}, daily limit ' + '{daily:d}, weekly limit {weekly:d}, monthly limit {monthly:d}, ' + 'yearly limit {yearly:d}')) +def snapshot_assert_configuration(session_browser, free_space, + timeline_enabled, software_enabled, hourly, + daily, weekly, monthly, yearly): + timeline_enabled = (timeline_enabled == 'enabled') + software_enabled = (software_enabled == 'enabled') + assert (free_space, timeline_enabled, software_enabled, hourly, daily, + weekly, monthly, yearly) == _get_configuration(session_browser) + + +def _delete_all(browser): + if _get_count(browser): + browser.find_by_id('select-all').check() + functional.submit(browser, browser.find_by_name('delete_selected')) + + confirm_button = browser.find_by_name('delete_confirm') + if confirm_button: # Only if redirected to confirm page + functional.submit(browser, confirm_button) + + +def _create(browser): + functional.visit(browser, '/plinth/sys/snapshot/manage/') + functional.submit(browser) # Click on 'Create Snapshot' + + +def _get_count(browser): + functional.visit(browser, '/plinth/sys/snapshot/manage/') + # Subtract 1 for table header + return len(browser.find_by_xpath('//tr')) - 1 + + +def _set_configuration(browser, free_space, timeline_enabled, software_enabled, + hourly, daily, weekly, monthly, yearly): + """Set the configuration for snapshots.""" + functional.nav_to_module(browser, 'snapshot') + browser.find_by_name('free_space').select(free_space / 100) + browser.find_by_name('enable_timeline_snapshots').select( + 'yes' if timeline_enabled else 'no') + browser.find_by_name('enable_software_snapshots').select( + 'yes' if software_enabled else 'no') + browser.find_by_name('hourly_limit').fill(hourly) + browser.find_by_name('daily_limit').fill(daily) + browser.find_by_name('weekly_limit').fill(weekly) + browser.find_by_name('monthly_limit').fill(monthly) + browser.find_by_name('yearly_limit').fill(yearly) + functional.submit(browser) + + +def _get_configuration(browser): + """Return the current configuration for snapshots.""" + functional.nav_to_module(browser, 'snapshot') + return (int(float(browser.find_by_name('free_space').value) * 100), + browser.find_by_name('enable_timeline_snapshots').value == 'yes', + browser.find_by_name('enable_software_snapshots').value == 'yes', + int(browser.find_by_name('hourly_limit').value), + int(browser.find_by_name('daily_limit').value), + int(browser.find_by_name('weekly_limit').value), + int(browser.find_by_name('monthly_limit').value), + int(browser.find_by_name('yearly_limit').value)) diff --git a/plinth/modules/storage/tests/test_functional.py b/plinth/modules/storage/tests/test_functional.py index 737f32ef1..aa1cce7e1 100644 --- a/plinth/modules/storage/tests/test_functional.py +++ b/plinth/modules/storage/tests/test_functional.py @@ -3,6 +3,23 @@ Functional, browser based tests for storage app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then + +from plinth.tests import functional scenarios('storage.feature') + + +@then('the root disk should be shown') +def storage_root_disk_is_shown(session_browser): + assert _is_root_disk_shown(session_browser) + + +@given(parsers.parse("I'm on the {name:w} page")) +def go_to_module(session_browser, name): + functional.nav_to_module(session_browser, name) + + +def _is_root_disk_shown(browser): + table_cells = browser.find_by_tag('td') + return any(cell.text == '/' for cell in table_cells) diff --git a/plinth/modules/syncthing/tests/test_functional.py b/plinth/modules/syncthing/tests/test_functional.py index eac5eb009..6709948d1 100644 --- a/plinth/modules/syncthing/tests/test_functional.py +++ b/plinth/modules/syncthing/tests/test_functional.py @@ -3,6 +3,141 @@ Functional, browser based tests for syncthing app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('syncthing.feature') + + +@given(parsers.parse('syncthing folder {folder_name:w} is not present')) +def syncthing_folder_not_present(session_browser, folder_name): + if _folder_is_present(session_browser, folder_name): + _remove_folder(session_browser, folder_name) + + +@given( + parsers.parse( + 'folder {folder_path:S} is present as syncthing folder {folder_name:w}' + )) +def syncthing_folder_present(session_browser, folder_name, folder_path): + if not _folder_is_present(session_browser, folder_name): + _add_folder(session_browser, folder_name, folder_path) + + +@when( + parsers.parse( + 'I add a folder {folder_path:S} as syncthing folder {folder_name:w}')) +def syncthing_add_folder(session_browser, folder_name, folder_path): + _add_folder(session_browser, folder_name, folder_path) + + +@when(parsers.parse('I remove syncthing folder {folder_name:w}')) +def syncthing_remove_folder(session_browser, folder_name): + _remove_folder(session_browser, folder_name) + + +@then(parsers.parse('syncthing folder {folder_name:w} should be present')) +def syncthing_assert_folder_present(session_browser, folder_name): + assert _folder_is_present(session_browser, folder_name) + + +@then(parsers.parse('syncthing folder {folder_name:w} should not be present')) +def syncthing_assert_folder_not_present(session_browser, folder_name): + assert not _folder_is_present(session_browser, folder_name) + + +def _load_main_interface(browser): + """Close the dialog boxes that many popup after visiting the URL.""" + functional.access_url(browser, 'syncthing') + + def service_is_available(): + if browser.is_element_present_by_xpath( + '//h1[text()="Service Unavailable"]'): + functional.access_url(browser, 'syncthing') + return False + + return True + + # After a backup restore, service may not be available immediately + functional.eventually(service_is_available) + + # Wait for javascript loading process to complete + browser.execute_script(''' + document.is_ui_online = false; + var old_console_log = console.log; + console.log = function(message) { + old_console_log.apply(null, arguments); + if (message == 'UIOnline') { + document.is_ui_online = true; + console.log = old_console_log; + } + }; + ''') + functional.eventually( + lambda: browser.evaluate_script('document.is_ui_online'), timeout=5) + + # Dismiss the Usage Reporting consent dialog + usage_reporting = browser.find_by_id('ur').first + functional.eventually(lambda: usage_reporting.visible, timeout=2) + if usage_reporting.visible: + yes_xpath = './/button[contains(@ng-click, "declineUR")]' + usage_reporting.find_by_xpath(yes_xpath).first.click() + functional.eventually(lambda: not usage_reporting.visible) + + +def _folder_is_present(browser, folder_name): + """Return whether a folder is present in Syncthing.""" + _load_main_interface(browser) + folder_names = browser.find_by_css('#folders .panel-title-text span') + folder_names = [folder_name.text for folder_name in folder_names] + return folder_name in folder_names + + +def _add_folder(browser, folder_name, folder_path): + """Add a new folder to Synthing.""" + _load_main_interface(browser) + add_folder_xpath = '//button[contains(@ng-click, "addFolder")]' + browser.find_by_xpath(add_folder_xpath).click() + + folder_dialog = browser.find_by_id('editFolder').first + functional.eventually(lambda: folder_dialog.visible) + browser.find_by_id('folderLabel').fill(folder_name) + browser.find_by_id('folderPath').fill(folder_path) + save_folder_xpath = './/button[contains(@ng-click, "saveFolder")]' + folder_dialog.find_by_xpath(save_folder_xpath).first.click() + functional.eventually(lambda: not folder_dialog.visible) + + +def _remove_folder(browser, folder_name): + """Remove a folder from Synthing.""" + _load_main_interface(browser) + + # Find folder + folder = None + for current_folder in browser.find_by_css('#folders > .panel'): + name = current_folder.find_by_css('.panel-title-text span').first.text + if name == folder_name: + folder = current_folder + break + + # Edit folder button + folder.find_by_css('button.panel-heading').first.click() + functional.eventually(lambda: folder.find_by_css('div.collapse.in')) + edit_folder_xpath = './/button[contains(@ng-click, "editFolder")]' + edit_folder_button = folder.find_by_xpath(edit_folder_xpath).first + edit_folder_button.click() + + # Edit folder dialog + folder_dialog = browser.find_by_id('editFolder').first + functional.eventually(lambda: folder_dialog.visible) + remove_button_xpath = './/button[contains(@data-target, "remove-folder")]' + folder_dialog.find_by_xpath(remove_button_xpath).first.click() + + # Remove confirmation dialog + remove_folder_dialog = browser.find_by_id('remove-folder-confirmation') + functional.eventually(lambda: remove_folder_dialog.visible) + remove_button_xpath = './/button[contains(@ng-click, "deleteFolder")]' + remove_folder_dialog.find_by_xpath(remove_button_xpath).first.click() + + functional.eventually(lambda: not folder_dialog.visible) diff --git a/plinth/modules/tahoe/tests/test_functional.py b/plinth/modules/tahoe/tests/test_functional.py index 899cc62c2..c6ebedb29 100644 --- a/plinth/modules/tahoe/tests/test_functional.py +++ b/plinth/modules/tahoe/tests/test_functional.py @@ -3,6 +3,72 @@ Functional, browser based tests for tahoe app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('tahoe.feature') + + +@then( + parsers.parse( + '{domain:S} should be a tahoe {introducer_type:w} introducer')) +def tahoe_assert_introducer(session_browser, domain, introducer_type): + assert _get_introducer(session_browser, domain, introducer_type) + + +@then( + parsers.parse( + '{domain:S} should not be a tahoe {introducer_type:w} introducer')) +def tahoe_assert_not_introducer(session_browser, domain, introducer_type): + assert not _get_introducer(session_browser, domain, introducer_type) + + +@given(parsers.parse('{domain:S} is not a tahoe introducer')) +def tahoe_given_remove_introducer(session_browser, domain): + if _get_introducer(session_browser, domain, 'connected'): + _remove_introducer(session_browser, domain) + + +@when(parsers.parse('I add {domain:S} as a tahoe introducer')) +def tahoe_add_introducer(session_browser, domain): + _add_introducer(session_browser, domain) + + +@given(parsers.parse('{domain:S} is a tahoe introducer')) +def tahoe_given_add_introducer(session_browser, domain): + if not _get_introducer(session_browser, domain, 'connected'): + _add_introducer(session_browser, domain) + + +@when(parsers.parse('I remove {domain:S} as a tahoe introducer')) +def tahoe_remove_introducer(session_browser, domain): + _remove_introducer(session_browser, domain) + + +def _get_introducer(browser, domain, introducer_type): + """Return an introducer element with a given type from tahoe-lafs.""" + functional.nav_to_module(browser, 'tahoe') + css_class = '.{}-introducers .introducer-furl'.format(introducer_type) + for furl in browser.find_by_css(css_class): + if domain in furl.text: + return furl.parent + + return None + + +def _add_introducer(browser, domain): + """Add a new introducer into tahoe-lafs.""" + functional.nav_to_module(browser, 'tahoe') + + furl = 'pb://ewe4zdz6kxn7xhuvc7izj2da2gpbgeir@tcp:{}:3456/' \ + 'fko4ivfwgqvybppwar3uehkx6spaaou7'.format(domain) + browser.fill('pet_name', 'testintroducer') + browser.fill('furl', furl) + functional.submit(browser, form_class='form-add-introducer') + + +def _remove_introducer(browser, domain): + """Remove an introducer from tahoe-lafs.""" + introducer = _get_introducer(browser, domain, 'connected') + functional.submit(browser, element=introducer.find_by_css('.form-remove')) diff --git a/plinth/modules/tor/tests/test_functional.py b/plinth/modules/tor/tests/test_functional.py index bad412e97..ae159ca46 100644 --- a/plinth/modules/tor/tests/test_functional.py +++ b/plinth/modules/tor/tests/test_functional.py @@ -3,6 +3,134 @@ Functional, browser based tests for tor app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional + +_TOR_FEATURE_TO_ELEMENT = { + 'relay': 'tor-relay_enabled', + 'bridge-relay': 'tor-bridge_relay_enabled', + 'hidden-services': 'tor-hs_enabled', + 'software': 'tor-apt_transport_tor_enabled' +} scenarios('tor.feature') + + +@given(parsers.parse('tor relay is {enabled:w}')) +def tor_given_relay_enable(session_browser, enabled): + _feature_enable(session_browser, 'relay', enabled) + + +@when(parsers.parse('I {enable:w} tor relay')) +def tor_relay_enable(session_browser, enable): + _feature_enable(session_browser, 'relay', enable) + + +@then(parsers.parse('tor relay should be {enabled:w}')) +def tor_assert_relay_enabled(session_browser, enabled): + _assert_feature_enabled(session_browser, 'relay', enabled) + + +@then(parsers.parse('tor {port_name:w} port should be displayed')) +def tor_assert_port_displayed(session_browser, port_name): + assert port_name in _get_relay_ports(session_browser) + + +@given(parsers.parse('tor bridge relay is {enabled:w}')) +def tor_given_bridge_relay_enable(session_browser, enabled): + _feature_enable(session_browser, 'bridge-relay', enabled) + + +@when(parsers.parse('I {enable:w} tor bridge relay')) +def tor_bridge_relay_enable(session_browser, enable): + _feature_enable(session_browser, 'bridge-relay', enable) + + +@then(parsers.parse('tor bridge relay should be {enabled:w}')) +def tor_assert_bridge_relay_enabled(session_browser, enabled): + _assert_feature_enabled(session_browser, 'bridge-relay', enabled) + + +@given(parsers.parse('tor hidden services are {enabled:w}')) +def tor_given_hidden_services_enable(session_browser, enabled): + _feature_enable(session_browser, 'hidden-services', enabled) + + +@when(parsers.parse('I {enable:w} tor hidden services')) +def tor_hidden_services_enable(session_browser, enable): + _feature_enable(session_browser, 'hidden-services', enable) + + +@then(parsers.parse('tor hidden services should be {enabled:w}')) +def tor_assert_hidden_services_enabled(session_browser, enabled): + _assert_feature_enabled(session_browser, 'hidden-services', enabled) + + +@then(parsers.parse('tor hidden services information should be displayed')) +def tor_assert_hidden_services(session_browser): + _assert_hidden_services(session_browser) + + +@given(parsers.parse('download software packages over tor is {enabled:w}')) +def tor_given_download_software_over_tor_enable(session_browser, enabled): + _feature_enable(session_browser, 'software', enabled) + + +@when(parsers.parse('I {enable:w} download software packages over tor')) +def tor_download_software_over_tor_enable(session_browser, enable): + _feature_enable(session_browser, 'software', enable) + + +@then( + parsers.parse('download software packages over tor should be {enabled:w}')) +def tor_assert_download_software_over_tor(session_browser, enabled): + _assert_feature_enabled(session_browser, 'software', enabled) + + +def _feature_enable(browser, feature, should_enable): + """Enable/disable a Tor feature.""" + if not isinstance(should_enable, bool): + should_enable = should_enable in ('enable', 'enabled') + + element_name = _TOR_FEATURE_TO_ELEMENT[feature] + functional.nav_to_module(browser, 'tor') + checkbox_element = browser.find_by_name(element_name).first + if should_enable == checkbox_element.checked: + return + + if should_enable: + if feature == 'bridge-relay': + browser.find_by_name('tor-relay_enabled').first.check() + + checkbox_element.check() + else: + checkbox_element.uncheck() + + functional.submit(browser, form_class='form-configuration') + functional.wait_for_config_update(browser, 'tor') + + +def _assert_feature_enabled(browser, feature, enabled): + """Assert whether Tor relay is enabled or disabled.""" + if not isinstance(enabled, bool): + enabled = enabled in ('enable', 'enabled') + + element_name = _TOR_FEATURE_TO_ELEMENT[feature] + functional.nav_to_module(browser, 'tor') + assert browser.find_by_name(element_name).first.checked == enabled + + +def _get_relay_ports(browser): + """Return the list of ports shown in the relay table.""" + functional.nav_to_module(browser, 'tor') + return [ + port_name.text + for port_name in browser.find_by_css('.tor-relay-port-name') + ] + + +def _assert_hidden_services(browser): + """Assert that hidden service information is shown.""" + functional.nav_to_module(browser, 'tor') + assert browser.find_by_css('.tor-hs .tor-hs-hostname') diff --git a/plinth/modules/transmission/tests/data/sample.torrent b/plinth/modules/transmission/tests/data/sample.torrent new file mode 100644 index 0000000000000000000000000000000000000000..4c2ed6bf45c6f87d3c745764b5ca62979cd87f4c GIT binary patch literal 46608 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

8Vx1>P?R*T7 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 literal 0 HcmV?d00001 diff --git a/plinth/modules/transmission/tests/test_functional.py b/plinth/modules/transmission/tests/test_functional.py index 1ece1c1b3..ae7236b2e 100644 --- a/plinth/modules/transmission/tests/test_functional.py +++ b/plinth/modules/transmission/tests/test_functional.py @@ -3,6 +3,67 @@ Functional, browser based tests for transmission app. """ -from pytest_bdd import scenarios +import os + +from pytest_bdd import parsers, scenarios, then, when + +from plinth.tests import functional scenarios('transmission.feature') + + +@when('all torrents are removed from transmission') +def transmission_remove_all_torrents(session_browser): + _remove_all_torrents(session_browser) + + +@when('I upload a sample torrent to transmission') +def transmission_upload_sample_torrent(session_browser): + _upload_sample_torrent(session_browser) + + +@then( + parsers.parse( + 'there should be {torrents_number:d} torrents listed in transmission')) +def transmission_assert_number_of_torrents(session_browser, torrents_number): + assert torrents_number == _get_number_of_torrents(session_browser) + + +def _remove_all_torrents(browser): + """Remove all torrents from transmission.""" + functional.visit(browser, '/transmission') + while True: + torrents = browser.find_by_css('#torrent_list .torrent') + if not torrents: + break + + torrents.first.click() + functional.eventually(browser.is_element_not_present_by_css, + args=['#toolbar-remove.disabled']) + browser.click_link_by_id('toolbar-remove') + functional.eventually( + browser.is_element_not_present_by_css, + args=['#dialog-container[style="display: none;"]']) + browser.click_link_by_id('dialog_confirm_button') + functional.eventually(browser.is_element_present_by_css, + args=['#toolbar-remove.disabled']) + + +def _upload_sample_torrent(browser): + """Upload a sample torrent into transmission.""" + functional.visit(browser, '/transmission') + file_path = os.path.join(os.path.dirname(__file__), 'data', + 'sample.torrent') + browser.click_link_by_id('toolbar-open') + functional.eventually(browser.is_element_not_present_by_css, + args=['#upload-container[style="display: none;"]']) + browser.attach_file('torrent_files[]', [file_path]) + browser.click_link_by_id('upload_confirm_button') + functional.eventually(browser.is_element_present_by_css, + args=['#torrent_list .torrent']) + + +def _get_number_of_torrents(browser): + """Return the number torrents currently in transmission.""" + functional.visit(browser, '/transmission') + return len(browser.find_by_css('#torrent_list .torrent')) diff --git a/plinth/modules/ttrss/tests/test_functional.py b/plinth/modules/ttrss/tests/test_functional.py index a94acba7b..ef44d5598 100644 --- a/plinth/modules/ttrss/tests/test_functional.py +++ b/plinth/modules/ttrss/tests/test_functional.py @@ -3,6 +3,73 @@ Functional, browser based tests for ttrss app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, scenarios, then, when + +from plinth.tests import functional scenarios('ttrss.feature') + + +@given('I subscribe to a feed in ttrss') +def ttrss_subscribe(session_browser): + _subscribe(session_browser) + + +@when('I unsubscribe from the feed in ttrss') +def ttrss_unsubscribe(session_browser): + _unsubscribe(session_browser) + + +@then('I should be subscribed to the feed in ttrss') +def ttrss_assert_subscribed(session_browser): + assert _is_subscribed(session_browser) + + +def _ttrss_load_main_interface(browser): + """Load the TT-RSS interface.""" + functional.access_url(browser, 'ttrss') + overlay = browser.find_by_id('overlay') + functional.eventually(lambda: not overlay.visible) + + +def _is_feed_shown(browser, invert=False): + return browser.is_text_present('Planet Debian') != invert + + +def _subscribe(browser): + """Subscribe to a feed in TT-RSS.""" + _ttrss_load_main_interface(browser) + browser.find_by_text('Actions...').click() + browser.find_by_text('Subscribe to feed...').click() + browser.find_by_id('feedDlg_feedUrl').fill( + 'https://planet.debian.org/atom.xml') + browser.find_by_text('Subscribe').click() + if browser.is_text_present('You are already subscribed to this feed.'): + browser.find_by_text('Cancel').click() + + expand = browser.find_by_css('span.dijitTreeExpandoClosed') + if expand: + expand.first.click() + + assert functional.eventually(_is_feed_shown, [browser]) + + +def _unsubscribe(browser): + """Unsubscribe from a feed in TT-RSS.""" + _ttrss_load_main_interface(browser) + expand = browser.find_by_css('span.dijitTreeExpandoClosed') + if expand: + expand.first.click() + + browser.find_by_text('Planet Debian').click() + browser.execute_script("quickMenuGo('qmcRemoveFeed')") + prompt = browser.get_alert() + prompt.accept() + + assert functional.eventually(_is_feed_shown, [browser, True]) + + +def _is_subscribed(browser): + """Return whether subscribed to a feed in TT-RSS.""" + _ttrss_load_main_interface(browser) + return browser.is_text_present('Planet Debian') diff --git a/plinth/modules/upgrades/tests/test_functional.py b/plinth/modules/upgrades/tests/test_functional.py index 272599b14..48e2c60fd 100644 --- a/plinth/modules/upgrades/tests/test_functional.py +++ b/plinth/modules/upgrades/tests/test_functional.py @@ -3,6 +3,47 @@ Functional, browser based tests for upgrades app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('upgrades.feature') + + +@given(parsers.parse('automatic upgrades are {enabled:w}')) +def upgrades_given_enable_automatic(session_browser, enabled): + should_enable = (enabled == 'enabled') + _enable_automatic(session_browser, should_enable) + + +@when(parsers.parse('I {enable:w} automatic upgrades')) +def upgrades_enable_automatic(session_browser, enable): + should_enable = (enable == 'enable') + _enable_automatic(session_browser, should_enable) + + +@then(parsers.parse('automatic upgrades should be {enabled:w}')) +def upgrades_assert_automatic(session_browser, enabled): + should_be_enabled = (enabled == 'enabled') + assert _get_automatic(session_browser) == should_be_enabled + + +def _enable_automatic(browser, should_enable): + """Enable/disable automatic software upgrades.""" + functional.nav_to_module(browser, 'upgrades') + checkbox_element = browser.find_by_name('auto_upgrades_enabled').first + if should_enable == checkbox_element.checked: + return + + if should_enable: + checkbox_element.check() + else: + checkbox_element.uncheck() + + functional.submit(browser) + + +def _get_automatic(browser): + """Return whether automatic software upgrades is enabled.""" + functional.nav_to_module(browser, 'upgrades') + return browser.find_by_name('auto_upgrades_enabled').first.checked diff --git a/plinth/modules/users/tests/test_functional.py b/plinth/modules/users/tests/test_functional.py index 92b26c540..02cfd78dc 100644 --- a/plinth/modules/users/tests/test_functional.py +++ b/plinth/modules/users/tests/test_functional.py @@ -3,6 +3,140 @@ Functional, browser based tests for users app. """ -from pytest_bdd import scenarios +from pytest_bdd import given, parsers, scenarios, then, when + +from plinth.tests import functional scenarios('users.feature') + +_language_codes = { + 'Deutsch': 'de', + 'Nederlands': 'nl', + 'Português': 'pt', + 'Türkçe': 'tr', + 'dansk': 'da', + 'español': 'es', + 'français': 'fr', + 'norsk (bokmål)': 'nb', + 'polski': 'pl', + 'svenska': 'sv', + 'Русский': 'ru', + 'తెలుగు': 'te', + '简体中文': 'zh-hans' +} + +_config_page_title_language_map = { + 'da': 'Generel Konfiguration', + 'de': 'Allgemeine Konfiguration', + 'es': 'Configuración general', + 'fr': 'Configuration générale', + 'nb': 'Generelt oppsett', + 'nl': 'Algemene Instellingen', + 'pl': 'Ustawienia główne', + 'pt': 'Configuração Geral', + 'ru': 'Общие настройки', + 'sv': 'Allmän Konfiguration', + 'te': 'సాధారణ ఆకృతీకరణ', + 'tr': 'Genel Yapılandırma', + 'zh-hans': '常规配置', +} + + +@given(parsers.parse("the user {name:w} doesn't exist")) +def new_user_does_not_exist(session_browser, name): + _delete_user(session_browser, name) + + +@given(parsers.parse('the user {name:w} exists')) +def test_user_exists(session_browser, name): + functional.nav_to_module(session_browser, 'users') + user_link = session_browser.find_link_by_href('/plinth/sys/users/' + name + + '/edit/') + if not user_link: + create_user(session_browser, name, 'secret123') + + +@when( + parsers.parse('I create a user named {name:w} with password {password:w}')) +def create_user(session_browser, name, password): + _create_user(session_browser, name, password) + + +@when(parsers.parse('I rename the user {old_name:w} to {new_name:w}')) +def rename_user(session_browser, old_name, new_name): + _rename_user(session_browser, old_name, new_name) + + +@when(parsers.parse('I delete the user {name:w}')) +def delete_user(session_browser, name): + _delete_user(session_browser, name) + + +@then(parsers.parse('{name:w} should be listed as a user')) +def new_user_is_listed(session_browser, name): + assert _is_user(session_browser, name) + + +@then(parsers.parse('{name:w} should not be listed as a user')) +def new_user_is_not_listed(session_browser, name): + assert not _is_user(session_browser, name) + + +@when('I change the language to ') +def change_language(session_browser, language): + _set_language(session_browser, _language_codes[language]) + + +@then('Plinth language should be ') +def plinth_language_should_be(session_browser, language): + assert _check_language(session_browser, _language_codes[language]) + + +def _create_user(browser, name, password): + functional.nav_to_module(browser, 'users') + with functional.wait_for_page_update(browser): + browser.find_link_by_href('/plinth/sys/users/create/').first.click() + browser.find_by_id('id_username').fill(name) + browser.find_by_id('id_password1').fill(password) + browser.find_by_id('id_password2').fill(password) + functional.submit(browser) + + +def _rename_user(browser, old_name, new_name): + functional.nav_to_module(browser, 'users') + with functional.wait_for_page_update(browser): + browser.find_link_by_href('/plinth/sys/users/' + old_name + + '/edit/').first.click() + browser.find_by_id('id_username').fill(new_name) + functional.submit(browser) + + +def _delete_user(browser, name): + functional.nav_to_module(browser, 'users') + delete_link = browser.find_link_by_href('/plinth/sys/users/' + name + + '/delete/') + if delete_link: + with functional.wait_for_page_update(browser): + delete_link.first.click() + functional.submit(browser) + + +def _is_user(browser, name): + functional.nav_to_module(browser, 'users') + edit_link = browser.find_link_by_href('/plinth/sys/users/' + name + + '/edit/') + return bool(edit_link) + + +def _set_language(browser, language_code): + username = functional.config['DEFAULT']['username'] + functional.visit(browser, '/plinth/sys/users/{}/edit/'.format(username)) + browser.find_by_xpath('//select[@id="id_language"]//option[@value="' + + language_code + '"]').first.click() + functional.submit(browser) + + +def _check_language(browser, language_code): + functional.nav_to_module(browser, 'config') + return browser.find_by_css('.app-titles').first.find_by_tag( + 'h2').first.value == _config_page_title_language_map[language_code] diff --git a/plinth/modules/users/tests/users.feature b/plinth/modules/users/tests/users.feature index 52ce217f0..fcebebd10 100644 --- a/plinth/modules/users/tests/users.feature +++ b/plinth/modules/users/tests/users.feature @@ -8,7 +8,7 @@ # TODO Scenario: Make user active # TODO Scenario: Change user password -@system @essential @users_groups +@system @essential @users Feature: Users and Groups Manage users and groups. diff --git a/plinth/tests/functional/__init__.py b/plinth/tests/functional/__init__.py index e69de29bb..2f757e2f5 100644 --- a/plinth/tests/functional/__init__.py +++ b/plinth/tests/functional/__init__.py @@ -0,0 +1,448 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Utilities for functional testing. +""" + +import configparser +import logging +import os +import pathlib +import tempfile +import time +from contextlib import contextmanager + +import requests +from selenium.common.exceptions import StaleElementReferenceException +from selenium.webdriver.support.ui import WebDriverWait + +config = configparser.ConfigParser() +config.read(pathlib.Path(__file__).with_name('config.ini')) + +config['DEFAULT']['url'] = os.environ.get('FREEDOMBOX_URL', + config['DEFAULT']['url']) +config['DEFAULT']['samba_port'] = os.environ.get( + 'FREEDOMBOX_SAMBA_PORT', config['DEFAULT']['samba_port']) + +logger = logging.getLogger(__name__) + +base_url = config['DEFAULT']['url'] + +_app_checkbox_id = { + 'tor': 'id_tor-enabled', + 'openvpn': 'id_openvpn-enabled', +} + +_apps_with_loaders = ['tor'] + +# unlisted sites just use '/' + site_name as url +_site_url = { + 'wiki': '/ikiwiki', + 'jsxc': '/plinth/apps/jsxc/jsxc/', + 'cockpit': '/_cockpit/', + 'syncthing': '/syncthing/', +} + +_sys_modules = [ + 'avahi', 'backups', 'bind', 'cockpit', 'config', 'datetime', 'diagnostics', + 'dynamicdns', 'firewall', 'letsencrypt', 'monkeysphere', 'names', + 'networks', 'pagekite', 'performance', 'power', 'security', 'snapshot', + 'ssh', 'storage', 'upgrades', 'users' +] + + +###################### +# Browser Extensions # +###################### +def visit(browser, path): + """Visit a path assuming the base URL as configured.""" + browser.visit(config['DEFAULT']['url'] + path) + + +def eventually(function, args=[], timeout=30): + """Execute a function returning a boolean expression till it returns + True or a timeout is reached""" + end_time = time.time() + timeout + current_time = time.time() + while current_time < end_time: + if function(*args): + return True + + time.sleep(0.1) + current_time = time.time() + + return False + + +class _PageLoaded(): + """ + Wait until a page (re)loaded. + + - element: Wait until this element gets stale + - expected_url (optional): Wait for the URL to become . + This can be necessary to wait for a redirect to finish. + """ + + def __init__(self, element, expected_url=None): + self.element = element + self.expected_url = expected_url + + def __call__(self, driver): + is_stale = False + try: + self.element.has_class('whatever_class') + except StaleElementReferenceException: + if self.expected_url is None: + is_stale = True + else: + if driver.url.endswith(self.expected_url): + is_stale = True + return is_stale + + +@contextmanager +def wait_for_page_update(browser, timeout=300, expected_url=None): + page_body = browser.find_by_tag('body').first + yield + WebDriverWait(browser, timeout).until(_PageLoaded(page_body, expected_url)) + + +def _get_site_url(site_name): + if site_name.startswith('share'): + site_name = site_name.replace('_', '/') + url = '/' + site_name + url = _site_url.get(site_name, url) + return url + + +def access_url(browser, site_name): + browser.visit(config['DEFAULT']['url'] + _get_site_url(site_name)) + + +def is_available(browser, site_name): + url_to_visit = config['DEFAULT']['url'] + _get_site_url(site_name) + browser.visit(url_to_visit) + time.sleep(3) + browser.reload() + not_404 = '404' not in browser.title + # The site might have a default path after the sitename, + # e.g /mediawiki/Main_Page + no_redirect = browser.url.startswith(url_to_visit.strip('/')) + return not_404 and no_redirect + + +def download_file(browser, url): + """Return file contents after downloading a URL.""" + cookies = browser.cookies.all() + response = requests.get(url, cookies=cookies, verify=False) + if response.status_code != 200: + raise Exception('URL download failed') + + return response.content + + +def download_file_outside_browser(url): + """Download a file to disk given a URL.""" + with tempfile.NamedTemporaryFile(delete=False) as temp_file: + logging.captureWarnings(True) + request = requests.get(url, verify=False) + logging.captureWarnings(False) + temp_file.write(request.content) + + return temp_file.name + + +########################### +# Form handling utilities # +########################### +def submit(browser, element=None, form_class=None, expected_url=None): + with wait_for_page_update(browser, expected_url=expected_url): + if element: + element.click() + elif form_class: + browser.find_by_css( + '.{} input[type=submit]'.format(form_class)).click() + else: + browser.find_by_css('input[type=submit]').click() + + +def change_checkbox_status(browser, app_name, checkbox_id, + change_status_to='enabled'): + """Change checkbox status.""" + checkbox = browser.find_by_id(checkbox_id) + if change_status_to == 'enabled': + checkbox.check() + else: + checkbox.uncheck() + + submit(browser, form_class='form-configuration') + + if app_name in _apps_with_loaders: + wait_for_config_update(browser, app_name) + + +def wait_for_config_update(browser, app_name): + while browser.is_element_present_by_css('.running-status.loading'): + time.sleep(0.1) + + +############################ +# Login handling utilities # +############################ +def is_login_prompt(browser): + return all( + [browser.find_by_id('id_username'), + browser.find_by_id('id_password')]) + + +def _create_admin_account(browser, username, password): + browser.find_by_id('id_username').fill(username) + browser.find_by_id('id_password1').fill(password) + browser.find_by_id('id_password2').fill(password) + submit(browser) + + +def login(browser, url, username, password): + + # XXX: Find a way to remove the hardcoded jsxc URL + if '/plinth/' not in browser.url or '/jsxc/jsxc' in browser.url: + browser.visit(url) + + apps_link = browser.find_link_by_href('/plinth/apps/') + if len(apps_link): + return + + login_button = browser.find_link_by_href('/plinth/accounts/login/') + if login_button: + login_button.first.click() + if login_button: + browser.fill('username', username) + browser.fill('password', password) + submit(browser) + else: + browser.visit(base_url + '/plinth/firstboot/welcome') + submit(browser) # click the "Start Setup" button + _create_admin_account(browser, username, password) + if '/network-topology-first-boot' in browser.url: + submit(browser, element=browser.find_by_name('skip')[0]) + + if '/internet-connection-type' in browser.url: + submit(browser, element=browser.find_by_name('skip')[0]) + + +################# +# App utilities # +################# +def nav_to_module(browser, module): + sys_or_apps = 'sys' if module in _sys_modules else 'apps' + required_url = base_url + f'/plinth/{sys_or_apps}/{module}/' + if browser.url != required_url: + browser.visit(required_url) + + +def app_select_domain_name(browser, app_name, domain_name): + browser.visit('{}/plinth/apps/{}/setup/'.format(base_url, app_name)) + drop_down = browser.find_by_id('id_domain_name') + drop_down.select(domain_name) + submit(browser, form_class='form-configuration') + + +######################### +# App install utilities # +######################### +def _find_install_button(browser, app_name): + nav_to_module(browser, app_name) + return browser.find_by_css('.form-install input[type=submit]') + + +def is_installed(browser, app_name): + install_button = _find_install_button(browser, app_name) + return not bool(install_button) + + +def install(browser, app_name): + install_button = _find_install_button(browser, app_name) + + def install_in_progress(): + selectors = [ + '.install-state-' + state + for state in ['pre', 'post', 'installing'] + ] + return any( + browser.is_element_present_by_css(selector) + for selector in selectors) + + def is_server_restarting(): + return browser.is_element_present_by_css('.neterror') + + def wait_for_install(): + if install_in_progress(): + time.sleep(1) + elif is_server_restarting(): + time.sleep(1) + browser.visit(browser.url) + else: + return + wait_for_install() + + if install_button: + install_button.click() + wait_for_install() + # sleep(2) # XXX This shouldn't be required. + + +################################ +# App enable/disable utilities # +################################ +def _change_app_status(browser, app_name, change_status_to='enabled'): + """Enable or disable application.""" + button = browser.find_by_css('button[name="app_enable_disable_button"]') + + if button: + should_enable_field = browser.find_by_id('id_should_enable') + if (should_enable_field.value == 'False' + and change_status_to == 'disabled') or ( + should_enable_field.value == 'True' + and change_status_to == 'enabled'): + submit(browser, element=button) + else: + checkbox_id = _app_checkbox_id[app_name] + change_checkbox_status(browser, app_name, checkbox_id, + change_status_to) + + if app_name in _apps_with_loaders: + wait_for_config_update(browser, app_name) + + +def app_enable(browser, app_name): + nav_to_module(browser, app_name) + _change_app_status(browser, app_name, 'enabled') + + +def app_disable(browser, app_name): + nav_to_module(browser, app_name) + _change_app_status(browser, app_name, 'disabled') + + +def app_can_be_disabled(browser, app_name): + """Return whether the application can be disabled.""" + nav_to_module(browser, app_name) + button = browser.find_by_css('button[name="app_enable_disable_button"]') + return bool(button) + + +######################### +# Domain name utilities # +######################### +def set_domain_name(browser, domain_name): + nav_to_module(browser, 'config') + browser.find_by_id('id_domainname').fill(domain_name) + submit(browser) + + +######################## +# Front page utilities # +######################## +def find_on_front_page(browser, app_name): + browser.visit(base_url) + shortcuts = browser.find_link_by_href(f'/{app_name}/') + return shortcuts + + +#################### +# Daemon utilities # +#################### +def service_is_running(browser, app_name): + nav_to_module(browser, app_name) + return len(browser.find_by_id('service-not-running')) == 0 + + +def service_is_not_running(browser, app_name): + nav_to_module(browser, app_name) + return len(browser.find_by_id('service-not-running')) != 0 + + +############################## +# System -> Config utilities # +############################## +def set_advanced_mode(browser, mode): + nav_to_module(browser, 'config') + advanced_mode = browser.find_by_id('id_advanced_mode') + if mode: + advanced_mode.check() + else: + advanced_mode.uncheck() + + submit(browser) + + +#################### +# Backup utilities # +#################### +def _click_button_and_confirm(browser, href): + buttons = browser.find_link_by_href(href) + if buttons: + buttons.first.click() + with wait_for_page_update(browser, + expected_url='/plinth/sys/backups/'): + submit(browser) + + +def _backup_delete_archive_by_name(browser, archive_name): + nav_to_module(browser, 'backups') + href = f'/plinth/sys/backups/root/delete/{archive_name}/' + _click_button_and_confirm(browser, href) + + +def backup_create(browser, app_name, archive_name=None): + install(browser, 'backups') + if archive_name: + _backup_delete_archive_by_name(browser, archive_name) + + browser.find_link_by_href('/plinth/sys/backups/create/').first.click() + browser.find_by_id('select-all').uncheck() + if archive_name: + browser.find_by_id('id_backups-name').fill(archive_name) + + # ensure the checkbox is scrolled into view + browser.execute_script('window.scrollTo(0, 0)') + browser.find_by_value(app_name).first.check() + submit(browser) + + +def backup_restore(browser, app_name, archive_name=None): + nav_to_module(browser, 'backups') + href = f'/plinth/sys/backups/root/restore-archive/{archive_name}/' + _click_button_and_confirm(browser, href) + + +###################### +# Networks utilities # +###################### +def networks_set_firewall_zone(browser, zone): + """"Set the network device firewall zone as internal or external.""" + nav_to_module(browser, 'networks') + device = browser.find_by_xpath( + '//span[contains(@class, "label-success") ' + 'and contains(@class, "connection-status-label")]/following::a').first + network_id = device['href'].split('/')[-3] + device.click() + edit_url = "/plinth/sys/networks/{}/edit/".format(network_id) + browser.find_link_by_href(edit_url).first.click() + browser.select('zone', zone) + browser.find_by_tag("form").first.find_by_tag('input')[-1].click() + + +################## +# Bind utilities # +################## +def set_forwarders(browser, forwarders): + """Set the forwarders list (space separated) in bind configuration.""" + nav_to_module(browser, 'bind') + browser.fill('forwarders', forwarders) + submit(browser, form_class='form-configuration') + + +def get_forwarders(browser): + """Return the forwarders list (space separated) in bind configuration.""" + nav_to_module(browser, 'bind') + return browser.find_by_name('forwarders').first.value diff --git a/plinth/tests/functional/step_definitions.py b/plinth/tests/functional/step_definitions.py new file mode 100644 index 000000000..034c002af --- /dev/null +++ b/plinth/tests/functional/step_definitions.py @@ -0,0 +1,163 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Step definitions used across apps. +""" + +import time + +import pytest +from pytest_bdd import given, parsers, then, when + +from plinth.tests import functional + + +@given("I'm a logged in user") +def logged_in_user(session_browser): + functional.login(session_browser, functional.base_url, + functional.config['DEFAULT']['username'], + functional.config['DEFAULT']['password']) + + +@given("I'm a logged out user") +def logged_out_user(session_browser): + functional.visit(session_browser, '/plinth/accounts/logout/') + + +@when("I log out") +def log_out_user(session_browser): + functional.visit(session_browser, '/plinth/accounts/logout/') + + +@given(parsers.parse('the {app_name:w} application is installed')) +def application_is_installed(session_browser, app_name): + functional.install(session_browser, app_name) + assert (functional.is_installed(session_browser, app_name)) + + +@given(parsers.parse('the {app_name:w} application is enabled')) +def application_is_enabled(session_browser, app_name): + functional.app_enable(session_browser, app_name) + + +@given(parsers.parse('the {app_name:w} application is disabled')) +def application_is_disabled(session_browser, app_name): + functional.app_disable(session_browser, app_name) + + +@when(parsers.parse('I enable the {app_name:w} application')) +def enable_application(session_browser, app_name): + functional.app_enable(session_browser, app_name) + + +@when(parsers.parse('I disable the {app_name:w} application')) +def disable_application(session_browser, app_name): + functional.app_disable(session_browser, app_name) + + +@given(parsers.parse('the {app_name:w} application can be disabled')) +def app_can_be_disabled(session_browser, app_name): + if not functional.app_can_be_disabled(session_browser, app_name): + pytest.skip(f'network time application can\'t be disabled') + + +@then(parsers.parse('the {service_name:w} service should be running')) +def service_should_be_running(session_browser, service_name): + assert functional.eventually(functional.service_is_running, + args=[session_browser, service_name]) + + +@then(parsers.parse('the {service_name:w} service should not be running')) +def service_should_not_be_running(session_browser, service_name): + assert functional.eventually(functional.service_is_not_running, + args=[session_browser, service_name]) + + +@then(parsers.parse('I should be prompted for login')) +def prompted_for_login(session_browser): + assert functional.is_login_prompt(session_browser) + + +@given(parsers.parse('the domain name is set to {domain:S}')) +def step_set_domain_name(session_browser, domain): + functional.set_domain_name(session_browser, domain) + + +@then(parsers.parse('the {site_name:w} site should be available')) +def site_should_be_available(session_browser, site_name): + assert functional.is_available(session_browser, site_name) + + +@then(parsers.parse('the {site_name:w} site should not be available')) +def site_should_not_be_available(session_browser, site_name): + assert not functional.is_available(session_browser, site_name) + + +@when(parsers.parse('I access {app_name:w} application')) +def access_application(session_browser, app_name): + functional.access_url(session_browser, app_name) + + +@given('advanced mode is on') +def advanced_mode_is_on(session_browser): + functional.set_advanced_mode(session_browser, True) + + +@when( + parsers.parse('I create a backup of the {app_name:w} app data with ' + 'name {archive_name:w}')) +def backup_create(session_browser, app_name, archive_name): + functional.backup_create(session_browser, app_name, archive_name) + + +@when(parsers.parse('I wait for {seconds} seconds')) +def sleep_for(seconds): + seconds = int(seconds) + time.sleep(seconds) + + +@when( + parsers.parse( + 'I restore the {app_name:w} app data backup with name {archive_name:w}' + )) +def backup_restore(session_browser, app_name, archive_name): + functional.backup_restore(session_browser, app_name, archive_name) + + +@given(parsers.parse('the network device is in the {zone:w} firewall zone')) +def networks_set_firewall_zone(session_browser, zone): + functional.networks_set_firewall_zone(session_browser, zone) + + +@given( + parsers.parse('the domain name for {app_name:w} is set to {domain_name:S}') +) +def select_domain_name(session_browser, app_name, domain_name): + functional.app_select_domain_name(session_browser, app_name, domain_name) + + +@then(parsers.parse('{app_name:w} app should be visible on the front page')) +def app_visible_on_front_page(session_browser, app_name): + shortcuts = functional.find_on_front_page(session_browser, app_name) + assert len(shortcuts) == 1 + + +@then(parsers.parse('{app_name:w} app should not be visible on the front page') + ) +def app_not_visible_on_front_page(session_browser, app_name): + shortcuts = functional.find_on_front_page(session_browser, app_name) + assert len(shortcuts) == 0 + + +@given(parsers.parse('bind forwarders are set to {forwarders}')) +def bind_given_set_forwarders(session_browser, forwarders): + functional.set_forwarders(session_browser, forwarders) + + +@when(parsers.parse('I set bind forwarders to {forwarders}')) +def bind_set_forwarders(session_browser, forwarders): + functional.set_forwarders(session_browser, forwarders) + + +@then(parsers.parse('bind forwarders should be {forwarders}')) +def bind_assert_forwarders(session_browser, forwarders): + assert functional.get_forwarders(session_browser) == forwarders diff --git a/plinth/tests/functional/step_definitions/__init__.py b/plinth/tests/functional/step_definitions/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/plinth/tests/functional/step_definitions/application.py b/plinth/tests/functional/step_definitions/application.py deleted file mode 100644 index 5692b5f64..000000000 --- a/plinth/tests/functional/step_definitions/application.py +++ /dev/null @@ -1,639 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -import pytest -import splinter -from pytest_bdd import given, parsers, then, when - -from ..support import application - - -@given(parsers.parse('the {app_name:w} application is installed')) -def application_is_installed(session_browser, app_name): - application.install(session_browser, app_name) - assert (application.is_installed(session_browser, app_name)) - - -@given(parsers.parse('the {app_name:w} application is enabled')) -def application_is_enabled(session_browser, app_name): - application.enable(session_browser, app_name) - - -@given(parsers.parse('the {app_name:w} application is disabled')) -def application_is_disabled(session_browser, app_name): - application.disable(session_browser, app_name) - - -@given(parsers.parse('the network time application is enabled')) -def ntp_is_enabled(session_browser): - application.enable(session_browser, 'ntp') - - -@given(parsers.parse('the network time application is disabled')) -def ntp_is_disabled(session_browser): - application.disable(session_browser, 'ntp') - - -@given(parsers.parse('the network time application can be disabled')) -def ntp_can_be_disabled(session_browser): - if not application.can_be_disabled(session_browser, 'ntp'): - pytest.skip(f'network time application can\'t be disabled') - - -@when(parsers.parse('I set the time zone to {time_zone:S}')) -def time_zone_set(session_browser, time_zone): - application.time_zone_set(session_browser, time_zone) - - -@then(parsers.parse('the time zone should be {time_zone:S}')) -def time_zone_assert(session_browser, time_zone): - assert time_zone == application.time_zone_get(session_browser) - - -@given(parsers.parse('the service discovery application is enabled')) -def avahi_is_enabled(session_browser): - application.enable(session_browser, 'avahi') - - -@given(parsers.parse('the service discovery application is disabled')) -def avahi_is_disabled(session_browser): - application.disable(session_browser, 'avahi') - - -@when(parsers.parse('I enable the {app_name:w} application')) -def enable_application(session_browser, app_name): - application.enable(session_browser, app_name) - - -@when(parsers.parse('I disable the {app_name:w} application')) -def disable_application(session_browser, app_name): - application.disable(session_browser, app_name) - - -@when(parsers.parse('I enable the network time application')) -def enable_ntp(session_browser): - application.enable(session_browser, 'ntp') - - -@when(parsers.parse('I disable the network time application')) -def disable_ntp(session_browser): - application.disable(session_browser, 'ntp') - - -@when(parsers.parse('I enable the service discovery application')) -def enable_avahi(session_browser): - application.enable(session_browser, 'avahi') - - -@when(parsers.parse('I disable the service discovery application')) -def disable_avahi(session_browser): - application.disable(session_browser, 'avahi') - - -@given( - parsers.parse('the domain name for {app_name:w} is set to {domain_name:S}') -) -def select_domain_name(session_browser, app_name, domain_name): - application.select_domain_name(session_browser, app_name, domain_name) - - -@given('the shadowsocks application is configured') -def configure_shadowsocks(session_browser): - application.configure_shadowsocks(session_browser, 'example.com', - 'fakepassword') - - -@when( - parsers.parse('I configure shadowsocks with server {server:S} and ' - 'password {password:w}')) -def configure_shadowsocks_with_details(session_browser, server, password): - application.configure_shadowsocks(session_browser, server, password) - - -@then( - parsers.parse('shadowsocks should be configured with server {server:S} ' - 'and password {password:w}')) -def assert_shadowsocks_configuration(session_browser, server, password): - assert ( - server, - password) == application.shadowsocks_get_configuration(session_browser) - - -@when(parsers.parse('I modify the maximum file size of coquelicot to {size:d}') - ) -def modify_max_file_size(session_browser, size): - application.modify_max_file_size(session_browser, size) - - -@then(parsers.parse('the maximum file size of coquelicot should be {size:d}')) -def assert_max_file_size(session_browser, size): - assert application.get_max_file_size(session_browser) == size - - -@when(parsers.parse('I modify the coquelicot upload password to {password:w}')) -def modify_upload_password(session_browser, password): - application.modify_upload_password(session_browser, password) - - -@given(parsers.parse('share {name:w} is not available')) -def remove_share(session_browser, name): - application.remove_share(session_browser, name) - - -@when(parsers.parse('I add a share {name:w} from path {path} for {group:w}')) -def add_share(session_browser, name, path, group): - application.add_share(session_browser, name, path, group) - - -@when( - parsers.parse('I edit share {old_name:w} to {new_name:w} from path {path} ' - 'for {group:w}')) -def edit_share(session_browser, old_name, new_name, path, group): - application.edit_share(session_browser, old_name, new_name, path, group) - - -@when(parsers.parse('I remove share {name:w}')) -def remove_share2(session_browser, name): - application.remove_share(session_browser, name) - - -@when(parsers.parse('I edit share {name:w} to be public')) -def edit_share_public_access(session_browser, name): - application.make_share_public(session_browser, name) - - -@then( - parsers.parse( - 'the share {name:w} should be listed from path {path} for {group:w}')) -def verify_share(session_browser, name, path, group): - application.verify_share(session_browser, name, path, group) - - -@then(parsers.parse('the share {name:w} should not be listed')) -def verify_invalid_share(session_browser, name): - with pytest.raises(splinter.exceptions.ElementDoesNotExist): - application.get_share(session_browser, name) - - -@then(parsers.parse('the share {name:w} should be accessible')) -def access_share(session_browser, name): - application.access_share(session_browser, name) - - -@then(parsers.parse('the share {name:w} should not exist')) -def verify_nonexistant_share(session_browser, name): - application.verify_nonexistant_share(session_browser, name) - - -@then(parsers.parse('the share {name:w} should not be accessible')) -def verify_inaccessible_share(session_browser, name): - application.verify_inaccessible_share(session_browser, name) - - -@when(parsers.parse('I enable mediawiki public registrations')) -def enable_mediawiki_public_registrations(session_browser): - application.enable_mediawiki_public_registrations(session_browser) - - -@when(parsers.parse('I disable mediawiki public registrations')) -def disable_mediawiki_public_registrations(session_browser): - application.disable_mediawiki_public_registrations(session_browser) - - -@when(parsers.parse('I enable mediawiki private mode')) -def enable_mediawiki_private_mode(session_browser): - application.enable_mediawiki_private_mode(session_browser) - - -@when(parsers.parse('I disable mediawiki private mode')) -def disable_mediawiki_private_mode(session_browser): - application.disable_mediawiki_private_mode(session_browser) - - -@when(parsers.parse('I set the mediawiki admin password to {password}')) -def set_mediawiki_admin_password(session_browser, password): - application.set_mediawiki_admin_password(session_browser, password) - - -@when(parsers.parse('I enable message archive management')) -def ejabberd_enable_archive_management(session_browser): - application.enable_ejabberd_message_archive_management(session_browser) - - -@when(parsers.parse('I disable message archive management')) -def ejabberd_disable_archive_management(session_browser): - application.disable_ejabberd_message_archive_management(session_browser) - - -@when('there is an ikiwiki wiki') -def ikiwiki_create_wiki_if_needed(session_browser): - application.ikiwiki_create_wiki_if_needed(session_browser) - - -@when('I delete the ikiwiki wiki') -def ikiwiki_delete_wiki(session_browser): - application.ikiwiki_delete_wiki(session_browser) - - -@then('the ikiwiki wiki should be restored') -def ikiwiki_should_exist(session_browser): - assert application.ikiwiki_wiki_exists(session_browser) - - -@given('I have added a contact to my roster') -def ejabberd_add_contact(session_browser): - application.ejabberd_add_contact(session_browser) - - -@when('I delete the contact from my roster') -def ejabberd_delete_contact(session_browser): - application.ejabberd_delete_contact(session_browser) - - -@then('I should have a contact on my roster') -def ejabberd_should_have_contact(session_browser): - assert application.ejabberd_has_contact(session_browser) - - -@given(parsers.parse('tor relay is {enabled:w}')) -def tor_given_relay_enable(session_browser, enabled): - application.tor_feature_enable(session_browser, 'relay', enabled) - - -@when(parsers.parse('I {enable:w} tor relay')) -def tor_relay_enable(session_browser, enable): - application.tor_feature_enable(session_browser, 'relay', enable) - - -@then(parsers.parse('tor relay should be {enabled:w}')) -def tor_assert_relay_enabled(session_browser, enabled): - application.tor_assert_feature_enabled(session_browser, 'relay', enabled) - - -@then(parsers.parse('tor {port_name:w} port should be displayed')) -def tor_assert_port_displayed(session_browser, port_name): - assert port_name in application.tor_get_relay_ports(session_browser) - - -@given(parsers.parse('tor bridge relay is {enabled:w}')) -def tor_given_bridge_relay_enable(session_browser, enabled): - application.tor_feature_enable(session_browser, 'bridge-relay', enabled) - - -@when(parsers.parse('I {enable:w} tor bridge relay')) -def tor_bridge_relay_enable(session_browser, enable): - application.tor_feature_enable(session_browser, 'bridge-relay', enable) - - -@then(parsers.parse('tor bridge relay should be {enabled:w}')) -def tor_assert_bridge_relay_enabled(session_browser, enabled): - application.tor_assert_feature_enabled(session_browser, 'bridge-relay', - enabled) - - -@given(parsers.parse('tor hidden services are {enabled:w}')) -def tor_given_hidden_services_enable(session_browser, enabled): - application.tor_feature_enable(session_browser, 'hidden-services', enabled) - - -@when(parsers.parse('I {enable:w} tor hidden services')) -def tor_hidden_services_enable(session_browser, enable): - application.tor_feature_enable(session_browser, 'hidden-services', enable) - - -@then(parsers.parse('tor hidden services should be {enabled:w}')) -def tor_assert_hidden_services_enabled(session_browser, enabled): - application.tor_assert_feature_enabled(session_browser, 'hidden-services', - enabled) - - -@then(parsers.parse('tor hidden services information should be displayed')) -def tor_assert_hidden_services(session_browser): - application.tor_assert_hidden_services(session_browser) - - -@given(parsers.parse('download software packages over tor is {enabled:w}')) -def tor_given_download_software_over_tor_enable(session_browser, enabled): - application.tor_feature_enable(session_browser, 'software', enabled) - - -@when(parsers.parse('I {enable:w} download software packages over tor')) -def tor_download_software_over_tor_enable(session_browser, enable): - application.tor_feature_enable(session_browser, 'software', enable) - - -@then( - parsers.parse('download software packages over tor should be {enabled:w}')) -def tor_assert_download_software_over_tor(session_browser, enabled): - application.tor_assert_feature_enabled(session_browser, 'software', - enabled) - - -@then( - parsers.parse( - '{domain:S} should be a tahoe {introducer_type:w} introducer')) -def tahoe_assert_introducer(session_browser, domain, introducer_type): - assert application.tahoe_get_introducer(session_browser, domain, - introducer_type) - - -@then( - parsers.parse( - '{domain:S} should not be a tahoe {introducer_type:w} introducer')) -def tahoe_assert_not_introducer(session_browser, domain, introducer_type): - assert not application.tahoe_get_introducer(session_browser, domain, - introducer_type) - - -@given(parsers.parse('{domain:S} is not a tahoe introducer')) -def tahoe_given_remove_introducer(session_browser, domain): - if application.tahoe_get_introducer(session_browser, domain, 'connected'): - application.tahoe_remove_introducer(session_browser, domain) - - -@when(parsers.parse('I add {domain:S} as a tahoe introducer')) -def tahoe_add_introducer(session_browser, domain): - application.tahoe_add_introducer(session_browser, domain) - - -@given(parsers.parse('{domain:S} is a tahoe introducer')) -def tahoe_given_add_introducer(session_browser, domain): - if not application.tahoe_get_introducer(session_browser, domain, - 'connected'): - application.tahoe_add_introducer(session_browser, domain) - - -@when(parsers.parse('I remove {domain:S} as a tahoe introducer')) -def tahoe_remove_introducer(session_browser, domain): - application.tahoe_remove_introducer(session_browser, domain) - - -@given('the access rights are set to "only the owner can view or make changes"' - ) -def radicale_given_owner_only(session_browser): - application.radicale_set_access_rights(session_browser, 'owner_only') - - -@given('the access rights are set to "any user can view, but only the ' - 'owner can make changes"') -def radicale_given_owner_write(session_browser): - application.radicale_set_access_rights(session_browser, 'owner_write') - - -@given('the access rights are set to "any user can view or make changes"') -def radicale_given_authenticated(session_browser): - application.radicale_set_access_rights(session_browser, 'authenticated') - - -@when('I change the access rights to "only the owner can view or make changes"' - ) -def radicale_set_owner_only(session_browser): - application.radicale_set_access_rights(session_browser, 'owner_only') - - -@when('I change the access rights to "any user can view, but only the ' - 'owner can make changes"') -def radicale_set_owner_write(session_browser): - application.radicale_set_access_rights(session_browser, 'owner_write') - - -@when('I change the access rights to "any user can view or make changes"') -def radicale_set_authenticated(session_browser): - application.radicale_set_access_rights(session_browser, 'authenticated') - - -@then('the access rights should be "only the owner can view or make changes"') -def radicale_check_owner_only(session_browser): - assert application.radicale_get_access_rights( - session_browser) == 'owner_only' - - -@then('the access rights should be "any user can view, but only the ' - 'owner can make changes"') -def radicale_check_owner_write(session_browser): - assert application.radicale_get_access_rights( - session_browser) == 'owner_write' - - -@then('the access rights should be "any user can view or make changes"') -def radicale_check_authenticated(session_browser): - assert application.radicale_get_access_rights( - session_browser) == 'authenticated' - - -@given(parsers.parse('the openvpn application is setup')) -def openvpn_setup(session_browser): - application.openvpn_setup(session_browser) - - -@given('I download openvpn profile') -def openvpn_download_profile(session_browser): - return application.openvpn_download_profile(session_browser) - - -@then('the openvpn profile should be downloadable') -def openvpn_profile_downloadable(session_browser): - application.openvpn_download_profile(session_browser) - - -@then('the openvpn profile downloaded should be same as before') -def openvpn_profile_download_compare(session_browser, - openvpn_download_profile): - new_profile = application.openvpn_download_profile(session_browser) - assert openvpn_download_profile == new_profile - - -@given('public access is enabled in searx') -def searx_public_access_enabled(session_browser): - application.searx_enable_public_access(session_browser) - - -@when('I enable public access in searx') -def searx_enable_public_access(session_browser): - application.searx_enable_public_access(session_browser) - - -@when('I disable public access in searx') -def searx_disable_public_access(session_browser): - application.searx_disable_public_access(session_browser) - - -@then(parsers.parse('{app_name:w} app should be visible on the front page')) -def app_visible_on_front_page(session_browser, app_name): - shortcuts = application.find_on_front_page(session_browser, app_name) - assert len(shortcuts) == 1 - - -@then(parsers.parse('{app_name:w} app should not be visible on the front page') - ) -def app_not_visible_on_front_page(session_browser, app_name): - shortcuts = application.find_on_front_page(session_browser, app_name) - assert len(shortcuts) == 0 - - -@given('a public repository') -@given('a repository') -@given('at least one repository exists') -def gitweb_repo(session_browser): - application.gitweb_create_repo(session_browser, 'Test-repo', 'public', - True) - - -@given('a private repository') -def gitweb_private_repo(session_browser): - application.gitweb_create_repo(session_browser, 'Test-repo', 'private', - True) - - -@given('both public and private repositories exist') -def gitweb_public_and_private_repo(session_browser): - application.gitweb_create_repo(session_browser, 'Test-repo', 'public', - True) - application.gitweb_create_repo(session_browser, 'Test-repo2', 'private', - True) - - -@given(parsers.parse("a {access:w} repository that doesn't exist")) -def gitweb_nonexistent_repo(session_browser, access): - application.gitweb_delete_repo(session_browser, 'Test-repo', - ignore_missing=True) - return dict(access=access) - - -@given('all repositories are private') -def gitweb_all_repositories_private(session_browser): - application.gitweb_set_all_repos_private(session_browser) - - -@given(parsers.parse('a repository metadata:\n{metadata}')) -def gitweb_repo_metadata(session_browser, metadata): - metadata_dict = {} - for item in metadata.split('\n'): - item = item.split(': ') - metadata_dict[item[0]] = item[1] - return metadata_dict - - -@when('I create the repository') -def gitweb_create_repo(session_browser, access): - application.gitweb_create_repo(session_browser, 'Test-repo', access) - - -@when('I delete the repository') -def gitweb_delete_repo(session_browser): - application.gitweb_delete_repo(session_browser, 'Test-repo') - - -@when('I set the metadata of the repository') -def gitweb_edit_repo_metadata(session_browser, gitweb_repo_metadata): - application.gitweb_edit_repo_metadata(session_browser, 'Test-repo', - gitweb_repo_metadata) - - -@when('using a git client') -def gitweb_using_git_client(): - pass - - -@then('the repository should be restored') -@then('the repository should be listed as a public') -def gitweb_repo_should_exists(session_browser): - assert application.gitweb_repo_exists(session_browser, 'Test-repo', - access='public') - - -@then('the repository should be listed as a private') -def gitweb_private_repo_should_exists(session_browser): - assert application.gitweb_repo_exists(session_browser, 'Test-repo', - 'private') - - -@then('the repository should not be listed') -def gitweb_repo_should_not_exist(session_browser, gitweb_repo): - assert not application.gitweb_repo_exists(session_browser, gitweb_repo) - - -@then('the public repository should be listed on gitweb') -@then('the repository should be listed on gitweb') -def gitweb_repo_should_exist_on_gitweb(session_browser): - assert application.gitweb_site_repo_exists(session_browser, 'Test-repo') - - -@then('the private repository should not be listed on gitweb') -def gitweb_private_repo_should_exists_on_gitweb(session_browser): - assert not application.gitweb_site_repo_exists(session_browser, - 'Test-repo2') - - -@then('the metadata of the repository should be as set') -def gitweb_repo_metadata_should_match(session_browser, gitweb_repo_metadata): - actual_metadata = application.gitweb_get_repo_metadata( - session_browser, 'Test-repo') - assert all(item in actual_metadata.items() - for item in gitweb_repo_metadata.items()) - - -@then('the repository should be publicly readable') -def gitweb_repo_publicly_readable(): - assert application.gitweb_repo_is_readable('Test-repo') - assert application.gitweb_repo_is_readable('Test-repo', - url_git_extension=True) - - -@then('the repository should not be publicly readable') -def gitweb_repo_not_publicly_readable(): - assert not application.gitweb_repo_is_readable('Test-repo') - assert not application.gitweb_repo_is_readable('Test-repo', - url_git_extension=True) - - -@then('the repository should not be publicly writable') -def gitweb_repo_not_publicly_writable(): - assert not application.gitweb_repo_is_writable('Test-repo') - assert not application.gitweb_repo_is_writable('Test-repo', - url_git_extension=True) - - -@then('the repository should be privately readable') -def gitweb_repo_privately_readable(): - assert application.gitweb_repo_is_readable('Test-repo', with_auth=True) - assert application.gitweb_repo_is_readable('Test-repo', with_auth=True, - url_git_extension=True) - - -@then('the repository should be privately writable') -def gitweb_repo_privately_writable(): - assert application.gitweb_repo_is_writable('Test-repo', with_auth=True) - assert application.gitweb_repo_is_writable('Test-repo', with_auth=True, - url_git_extension=True) - - -@when(parsers.parse('I {task:w} the {share_type:w} samba share')) -def samba_enable_share(session_browser, task, share_type): - if task == 'enable': - application.samba_set_share(session_browser, share_type, - status='enabled') - elif task == 'disable': - application.samba_set_share(session_browser, share_type, - status='disabled') - - -@then(parsers.parse('I can write to the {share_type:w} samba share')) -def samba_share_should_be_writable(share_type): - application.samba_assert_share_is_writable(share_type) - - -@then(parsers.parse('a guest user can write to the {share_type:w} samba share') - ) -def samba_share_should_be_writable_to_guest(share_type): - application.samba_assert_share_is_writable(share_type, as_guest=True) - - -@then( - parsers.parse('a guest user can\'t access the {share_type:w} samba share')) -def samba_share_should_not_be_accessible_to_guest(share_type): - application.samba_assert_share_is_not_accessible(share_type, as_guest=True) - - -@then(parsers.parse('the {share_type:w} samba share should not be available')) -def samba_share_should_not_be_available(share_type): - application.samba_assert_share_is_not_available(share_type) diff --git a/plinth/tests/functional/step_definitions/interface.py b/plinth/tests/functional/step_definitions/interface.py deleted file mode 100644 index 512d7d953..000000000 --- a/plinth/tests/functional/step_definitions/interface.py +++ /dev/null @@ -1,90 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -from pytest_bdd import given, parsers, then, when - -from ..support import config, interface - -default_url = config['DEFAULT']['url'] - - -@given("I'm a logged in user") -def logged_in_user(session_browser): - interface.login(session_browser, default_url, - config['DEFAULT']['username'], - config['DEFAULT']['password']) - - -@given("I'm a logged out user") -def logged_out_user(session_browser): - session_browser.visit(default_url + '/plinth/accounts/logout/') - - -@when("I log out") -def log_out_user(session_browser): - session_browser.visit(default_url + '/plinth/accounts/logout/') - - -@then(parsers.parse('I should be prompted for login')) -def prompted_for_login(session_browser): - assert interface.is_login_prompt(session_browser) - - -@given(parsers.parse("the user {name:w} doesn't exist")) -def new_user_does_not_exist(session_browser, name): - interface.delete_user(session_browser, name) - - -@given(parsers.parse('the user {name:w} exists')) -def test_user_exists(session_browser, name): - interface.nav_to_module(session_browser, 'users') - user_link = session_browser.find_link_by_href('/plinth/sys/users/' + name + - '/edit/') - if not user_link: - create_user(session_browser, name, 'secret123') - - -@when( - parsers.parse('I create a user named {name:w} with password {password:w}')) -def create_user(session_browser, name, password): - interface.create_user(session_browser, name, password) - - -@when(parsers.parse('I rename the user {old_name:w} to {new_name:w}')) -def rename_user(session_browser, old_name, new_name): - interface.rename_user(session_browser, old_name, new_name) - - -@when(parsers.parse('I delete the user {name:w}')) -def delete_user(session_browser, name): - interface.delete_user(session_browser, name) - - -@then(parsers.parse('{name:w} should be listed as a user')) -def new_user_is_listed(session_browser, name): - assert interface.is_user(session_browser, name) - - -@then(parsers.parse('{name:w} should not be listed as a user')) -def new_user_is_not_listed(session_browser, name): - assert not interface.is_user(session_browser, name) - - -@given('a sample local file') -def sample_local_file(): - file_path, contents = interface.create_sample_local_file() - return dict(file_path=file_path, contents=contents) - - -@when('I go to the status logs page') -def help_go_to_status_logs(session_browser): - interface.go_to_status_logs(session_browser) - - -@then('status logs should be shown') -def help_status_logs_are_shown(session_browser): - assert interface.are_status_logs_shown(session_browser) - - -@given(parsers.parse("I'm on the {name:w} page")) -def go_to_module(session_browser, name): - interface.nav_to_module(session_browser, name) diff --git a/plinth/tests/functional/step_definitions/service.py b/plinth/tests/functional/step_definitions/service.py deleted file mode 100644 index ec543d136..000000000 --- a/plinth/tests/functional/step_definitions/service.py +++ /dev/null @@ -1,37 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -from pytest_bdd import parsers, then - -from ..support import service -from ..support.service import eventually - - -@then(parsers.parse('the {service_name:w} service should be running')) -def service_should_be_running(session_browser, service_name): - assert eventually(service.is_running, args=[session_browser, service_name]) - - -@then(parsers.parse('the {service_name:w} service should not be running')) -def service_should_not_be_running(session_browser, service_name): - assert eventually(service.is_not_running, - args=[session_browser, service_name]) - - -@then(parsers.parse('the network time service should be running')) -def ntp_should_be_running(session_browser): - assert service.is_running(session_browser, 'ntp') - - -@then(parsers.parse('the network time service should not be running')) -def ntp_should_not_be_running(session_browser): - assert not service.is_running(session_browser, 'ntp') - - -@then(parsers.parse('the service discovery service should be running')) -def avahi_should_be_running(session_browser): - assert service.is_running(session_browser, 'avahi') - - -@then(parsers.parse('the service discovery service should not be running')) -def avahi_should_not_be_running(session_browser): - assert not service.is_running(session_browser, 'avahi') diff --git a/plinth/tests/functional/step_definitions/site.py b/plinth/tests/functional/step_definitions/site.py deleted file mode 100644 index c5b23d6ba..000000000 --- a/plinth/tests/functional/step_definitions/site.py +++ /dev/null @@ -1,234 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -from pytest_bdd import given, parsers, then, when - -from ..support import interface, site - - -@then(parsers.parse('the {site_name:w} site should be available')) -def site_should_be_available(session_browser, site_name): - assert site.is_available(session_browser, site_name) - - -@then(parsers.parse('the {site_name:w} site should not be available')) -def site_should_not_be_available(session_browser, site_name): - assert not site.is_available(session_browser, site_name) - - -@when(parsers.parse('I access {app_name:w} application')) -def access_application(session_browser, app_name): - site.access_url(session_browser, app_name) - - -@when( - parsers.parse( - 'I upload an image named {image:S} to mediawiki with credentials ' - '{username:w} and {password:w}')) -def upload_image(session_browser, username, password, image): - site.upload_image_mediawiki(session_browser, username, password, image) - - -@then(parsers.parse('there should be {image:S} image')) -def uploaded_image_should_be_available(session_browser, image): - uploaded_image = site.get_uploaded_image_in_mediawiki( - session_browser, image) - assert image.lower() == uploaded_image.lower() - - -@then( - parsers.parse( - 'I should be able to login to coquelicot with password {password:w}')) -def verify_upload_password(session_browser, password): - site.verify_coquelicot_upload_password(session_browser, password) - - -@when( - parsers.parse('I upload the sample local file to coquelicot with password ' - '{password:w}')) -def coquelicot_upload_file(session_browser, sample_local_file, password): - url = site.upload_file_to_coquelicot(session_browser, - sample_local_file['file_path'], - password) - sample_local_file['upload_url'] = url - - -@when('I download the uploaded file from coquelicot') -def coquelicot_download_file(sample_local_file): - file_path = interface.download_file(sample_local_file['upload_url']) - sample_local_file['download_path'] = file_path - - -@then('contents of downloaded sample file should be same as sample local file') -def coquelicot_compare_upload_download_files(sample_local_file): - interface.compare_files(sample_local_file['file_path'], - sample_local_file['download_path']) - - -@then(parsers.parse('the mediawiki site should allow creating accounts')) -def mediawiki_allows_creating_accounts(session_browser): - site.verify_mediawiki_create_account_link(session_browser) - - -@then(parsers.parse('the mediawiki site should not allow creating accounts')) -def mediawiki_does_not_allow_creating_accounts(session_browser): - site.verify_mediawiki_no_create_account_link(session_browser) - - -@then( - parsers.parse('the mediawiki site should allow anonymous reads and writes') -) -def mediawiki_allows_anonymous_reads_edits(session_browser): - site.verify_mediawiki_anonymous_reads_edits_link(session_browser) - - -@then( - parsers.parse( - 'the mediawiki site should not allow anonymous reads and writes')) -def mediawiki_does_not_allow__account_creation_anonymous_reads_edits( - session_browser): - site.verify_mediawiki_no_anonymous_reads_edits_link(session_browser) - - -@then( - parsers.parse( - 'I should see the Upload File option in the side pane when logged in ' - 'with credentials {username:w} and {password:w}')) -def login_to_mediawiki_with_credentials(session_browser, username, password): - site.login_to_mediawiki_with_credentials(session_browser, username, - password) - - -@when('I delete the mediawiki main page') -def mediawiki_delete_main_page(session_browser): - site.mediawiki_delete_main_page(session_browser) - - -@then('the mediawiki main page should be restored') -def mediawiki_verify_text(session_browser): - assert site.mediawiki_has_main_page(session_browser) - - -@when('all ed2k files are removed from mldonkey') -def mldonkey_remove_all_ed2k_files(session_browser): - site.mldonkey_remove_all_ed2k_files(session_browser) - - -@when('I upload a sample ed2k file to mldonkey') -def mldonkey_upload_sample_ed2k_file(session_browser): - site.mldonkey_upload_sample_ed2k_file(session_browser) - - -@then( - parsers.parse( - 'there should be {ed2k_files_number:d} ed2k files listed in mldonkey')) -def mldonkey_assert_number_of_ed2k_files(session_browser, ed2k_files_number): - assert ed2k_files_number == site.mldonkey_get_number_of_ed2k_files( - session_browser) - - -@when('all torrents are removed from transmission') -def transmission_remove_all_torrents(session_browser): - site.transmission_remove_all_torrents(session_browser) - - -@when('I upload a sample torrent to transmission') -def transmission_upload_sample_torrent(session_browser): - site.transmission_upload_sample_torrent(session_browser) - - -@then( - parsers.parse( - 'there should be {torrents_number:d} torrents listed in transmission')) -def transmission_assert_number_of_torrents(session_browser, torrents_number): - assert torrents_number == site.transmission_get_number_of_torrents( - session_browser) - - -@when('all torrents are removed from deluge') -def deluge_remove_all_torrents(session_browser): - site.deluge_remove_all_torrents(session_browser) - - -@when('I upload a sample torrent to deluge') -def deluge_upload_sample_torrent(session_browser): - site.deluge_upload_sample_torrent(session_browser) - - -@then( - parsers.parse( - 'there should be {torrents_number:d} torrents listed in deluge')) -def deluge_assert_number_of_torrents(session_browser, torrents_number): - assert torrents_number == site.deluge_get_number_of_torrents( - session_browser) - - -@then('the calendar should be available') -def assert_calendar_is_available(session_browser): - assert site.calendar_is_available(session_browser) - - -@then('the calendar should not be available') -def assert_calendar_is_not_available(session_browser): - assert not site.calendar_is_available(session_browser) - - -@then('the addressbook should be available') -def assert_addressbook_is_available(session_browser): - assert site.addressbook_is_available(session_browser) - - -@then('the addressbook should not be available') -def assert_addressbook_is_not_available(session_browser): - assert not site.addressbook_is_available(session_browser) - - -@given(parsers.parse('syncthing folder {folder_name:w} is not present')) -def syncthing_folder_not_present(session_browser, folder_name): - if site.syncthing_folder_is_present(session_browser, folder_name): - site.syncthing_remove_folder(session_browser, folder_name) - - -@given( - parsers.parse( - 'folder {folder_path:S} is present as syncthing folder {folder_name:w}' - )) -def syncthing_folder_present(session_browser, folder_name, folder_path): - if not site.syncthing_folder_is_present(session_browser, folder_name): - site.syncthing_add_folder(session_browser, folder_name, folder_path) - - -@when( - parsers.parse( - 'I add a folder {folder_path:S} as syncthing folder {folder_name:w}')) -def syncthing_add_folder(session_browser, folder_name, folder_path): - site.syncthing_add_folder(session_browser, folder_name, folder_path) - - -@when(parsers.parse('I remove syncthing folder {folder_name:w}')) -def syncthing_remove_folder(session_browser, folder_name): - site.syncthing_remove_folder(session_browser, folder_name) - - -@then(parsers.parse('syncthing folder {folder_name:w} should be present')) -def syncthing_assert_folder_present(session_browser, folder_name): - assert site.syncthing_folder_is_present(session_browser, folder_name) - - -@then(parsers.parse('syncthing folder {folder_name:w} should not be present')) -def syncthing_assert_folder_not_present(session_browser, folder_name): - assert not site.syncthing_folder_is_present(session_browser, folder_name) - - -@given('I subscribe to a feed in ttrss') -def ttrss_subscribe(session_browser): - site.ttrss_subscribe(session_browser) - - -@when('I unsubscribe from the feed in ttrss') -def ttrss_unsubscribe(session_browser): - site.ttrss_unsubscribe(session_browser) - - -@then('I should be subscribed to the feed in ttrss') -def ttrss_assert_subscribed(session_browser): - assert site.ttrss_is_subscribed(session_browser) diff --git a/plinth/tests/functional/step_definitions/system.py b/plinth/tests/functional/step_definitions/system.py deleted file mode 100644 index 89a308c01..000000000 --- a/plinth/tests/functional/step_definitions/system.py +++ /dev/null @@ -1,341 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -import os -import time - -from pytest import fixture -from pytest_bdd import given, parsers, then, when - -from ..support import system - -language_codes = { - 'Deutsch': 'de', - 'Nederlands': 'nl', - 'Português': 'pt', - 'Türkçe': 'tr', - 'dansk': 'da', - 'español': 'es', - 'français': 'fr', - 'norsk (bokmål)': 'nb', - 'polski': 'pl', - 'svenska': 'sv', - 'Русский': 'ru', - 'తెలుగు': 'te', - '简体中文': 'zh-hans' -} - - -@fixture(scope='session') -def downloaded_file_info(): - return dict() - - -@given(parsers.parse('the home page is {app_name:w}')) -def set_home_page(session_browser, app_name): - system.set_home_page(session_browser, app_name) - - -@given(parsers.parse('the domain name is set to {domain:S}')) -def set_domain_name(session_browser, domain): - system.set_domain_name(session_browser, domain) - - -@given('advanced mode is on') -def advanced_mode_is_on(session_browser): - system.set_advanced_mode(session_browser, True) - - -@when(parsers.parse('I change the hostname to {hostname:w}')) -def change_hostname_to(session_browser, hostname): - system.set_hostname(session_browser, hostname) - - -@when(parsers.parse('I change the domain name to {domain:S}')) -def change_domain_name_to(session_browser, domain): - system.set_domain_name(session_browser, domain) - - -@when(parsers.parse('I change the home page to {app_name:w}')) -def change_home_page_to(session_browser, app_name): - system.set_home_page(session_browser, app_name) - - -@when('I change the language to ') -def change_language(session_browser, language): - system.set_language(session_browser, language_codes[language]) - - -@then(parsers.parse('the hostname should be {hostname:w}')) -def hostname_should_be(session_browser, hostname): - assert system.get_hostname(session_browser) == hostname - - -@then(parsers.parse('the domain name should be {domain:S}')) -def domain_name_should_be(session_browser, domain): - assert system.get_domain_name(session_browser) == domain - - -@then('Plinth language should be ') -def plinth_language_should_be(session_browser, language): - assert system.check_language(session_browser, language_codes[language]) - - -@given('the list of snapshots is empty') -def empty_snapshots_list(session_browser): - system.delete_all_snapshots(session_browser) - - -@when('I manually create a snapshot') -def create_snapshot(session_browser): - system.create_snapshot(session_browser) - - -@then(parsers.parse('there should be {count:d} snapshot in the list')) -def verify_snapshot_count(session_browser, count): - num_snapshots = system.get_snapshot_count(session_browser) - assert num_snapshots == count - - -@given( - parsers.parse( - 'snapshots are configured with free space {free_space:d}, timeline ' - 'snapshots {timeline_enabled:w}, software snapshots ' - '{software_enabled:w}, hourly limit {hourly:d}, daily limit {daily:d}' - ', weekly limit {weekly:d}, monthly limit {monthly:d}, yearly limit ' - '{yearly:d}')) -def snapshot_given_set_configuration(session_browser, free_space, - timeline_enabled, software_enabled, - hourly, daily, weekly, monthly, yearly): - timeline_enabled = (timeline_enabled == 'enabled') - software_enabled = (software_enabled == 'enabled') - system.snapshot_set_configuration(session_browser, free_space, - timeline_enabled, software_enabled, - hourly, daily, weekly, monthly, yearly) - - -@when( - parsers.parse( - 'I configure snapshots with free space {free_space:d}, ' - 'timeline snapshots {timeline_enabled:w}, ' - 'software snapshots {software_enabled:w}, hourly limit {hourly:d}, ' - 'daily limit {daily:d}, weekly limit {weekly:d}, monthly limit ' - '{monthly:d}, yearly limit {yearly:d}')) -def snapshot_set_configuration(session_browser, free_space, timeline_enabled, - software_enabled, hourly, daily, weekly, - monthly, yearly): - timeline_enabled = (timeline_enabled == 'enabled') - software_enabled = (software_enabled == 'enabled') - system.snapshot_set_configuration(session_browser, free_space, - timeline_enabled, software_enabled, - hourly, daily, weekly, monthly, yearly) - - -@then( - parsers.parse( - 'snapshots should be configured with free space {free_space:d}, ' - 'timeline snapshots {timeline_enabled:w}, software snapshots ' - '{software_enabled:w}, hourly limit {hourly:d}, daily limit ' - '{daily:d}, weekly limit {weekly:d}, monthly limit {monthly:d}, ' - 'yearly limit {yearly:d}')) -def snapshot_assert_configuration(session_browser, free_space, - timeline_enabled, software_enabled, hourly, - daily, weekly, monthly, yearly): - timeline_enabled = (timeline_enabled == 'enabled') - software_enabled = (software_enabled == 'enabled') - assert (free_space, timeline_enabled, software_enabled, hourly, daily, - weekly, monthly, - yearly) == system.snapshot_get_configuration(session_browser) - - -@then(parsers.parse('the home page should be {app_name:w}')) -def home_page_should_be(session_browser, app_name): - assert system.check_home_page_redirect(session_browser, app_name) - - -@given('dynamicdns is configured') -def dynamicdns_configure(session_browser): - system.dynamicdns_configure(session_browser) - - -@when('I change the dynamicdns configuration') -def dynamicdns_change_config(session_browser): - system.dynamicdns_change_config(session_browser) - - -@then('dynamicdns should have the original configuration') -def dynamicdns_has_original_config(session_browser): - assert system.dynamicdns_has_original_config(session_browser) - - -@when( - parsers.parse('I create a backup of the {app_name:w} app data with ' - 'name {archive_name:w}')) -def backup_create(session_browser, app_name, archive_name): - system.backup_create(session_browser, app_name, archive_name) - - -@when( - parsers.parse('I download the app data backup with name {archive_name:w}')) -def backup_download(session_browser, downloaded_file_info, archive_name): - file_path = system.download_backup(session_browser, archive_name) - downloaded_file_info['path'] = file_path - - -@when( - parsers.parse( - 'I restore the {app_name:w} app data backup with name {archive_name:w}' - )) -def backup_restore(session_browser, app_name, archive_name): - system.backup_restore(session_browser, app_name, archive_name) - - -@when(parsers.parse('I restore the downloaded app data backup')) -def backup_restore_from_upload(session_browser, app_name, - downloaded_file_info): - path = downloaded_file_info["path"] - try: - system.backup_upload_and_restore(session_browser, app_name, path) - except Exception as err: - raise err - finally: - os.remove(path) - - -@when( - parsers.parse('I configure pagekite with host {host:S}, port {port:d}, ' - 'kite name {kite_name:S} and kite secret {kite_secret:w}')) -def pagekite_configure(session_browser, host, port, kite_name, kite_secret): - system.pagekite_configure(session_browser, host, port, kite_name, - kite_secret) - - -@then( - parsers.parse( - 'pagekite should be configured with host {host:S}, port {port:d}, ' - 'kite name {kite_name:S} and kite secret {kite_secret:w}')) -def pagekite_assert_configured(session_browser, host, port, kite_name, - kite_secret): - assert (host, port, kite_name, - kite_secret) == system.pagekite_get_configuration(session_browser) - - -@given(parsers.parse('bind forwarders are set to {forwarders}')) -def bind_given_set_forwarders(session_browser, forwarders): - system.bind_set_forwarders(session_browser, forwarders) - - -@when(parsers.parse('I set bind forwarders to {forwarders}')) -def bind_set_forwarders(session_browser, forwarders): - system.bind_set_forwarders(session_browser, forwarders) - - -@then(parsers.parse('bind forwarders should be {forwarders}')) -def bind_assert_forwarders(session_browser, forwarders): - assert system.bind_get_forwarders(session_browser) == forwarders - - -@given(parsers.parse('bind DNSSEC is {enable:w}')) -def bind_given_enable_dnssec(session_browser, enable): - should_enable = (enable == 'enabled') - system.bind_enable_dnssec(session_browser, should_enable) - - -@when(parsers.parse('I {enable:w} bind DNSSEC')) -def bind_enable_dnssec(session_browser, enable): - should_enable = (enable == 'enable') - system.bind_enable_dnssec(session_browser, should_enable) - - -@then(parsers.parse('bind DNSSEC should be {enabled:w}')) -def bind_assert_dnssec(session_browser, enabled): - assert system.bind_get_dnssec(session_browser) == (enabled == 'enabled') - - -@given(parsers.parse('restricted console logins are {enabled}')) -def security_given_enable_restricted_logins(session_browser, enabled): - should_enable = (enabled == 'enabled') - system.security_enable_restricted_logins(session_browser, should_enable) - - -@when(parsers.parse('I {enable} restricted console logins')) -def security_enable_restricted_logins(session_browser, enable): - should_enable = (enable == 'enable') - system.security_enable_restricted_logins(session_browser, should_enable) - - -@then(parsers.parse('restricted console logins should be {enabled}')) -def security_assert_restricted_logins(session_browser, enabled): - enabled = (enabled == 'enabled') - assert system.security_get_restricted_logins(session_browser) == enabled - - -@given(parsers.parse('automatic upgrades are {enabled:w}')) -def upgrades_given_enable_automatic(session_browser, enabled): - should_enable = (enabled == 'enabled') - system.upgrades_enable_automatic(session_browser, should_enable) - - -@when(parsers.parse('I {enable:w} automatic upgrades')) -def upgrades_enable_automatic(session_browser, enable): - should_enable = (enable == 'enable') - system.upgrades_enable_automatic(session_browser, should_enable) - - -@then(parsers.parse('automatic upgrades should be {enabled:w}')) -def upgrades_assert_automatic(session_browser, enabled): - should_be_enabled = (enabled == 'enabled') - assert system.upgrades_get_automatic(session_browser) == should_be_enabled - - -@given( - parsers.parse( - 'the {key_type:w} key for {domain:S} is imported in monkeysphere')) -def monkeysphere_given_import_key(session_browser, key_type, domain): - system.monkeysphere_import_key(session_browser, key_type.lower(), domain) - - -@when(parsers.parse('I import {key_type:w} key for {domain:S} in monkeysphere') - ) -def monkeysphere_import_key(session_browser, key_type, domain): - system.monkeysphere_import_key(session_browser, key_type.lower(), domain) - - -@then( - parsers.parse( - 'the {key_type:w} key should imported for {domain:S} in monkeysphere')) -def monkeysphere_assert_imported_key(session_browser, key_type, domain): - system.monkeysphere_assert_imported_key(session_browser, key_type.lower(), - domain) - - -@then( - parsers.parse('I should be able to publish {key_type:w} key for ' - '{domain:S} in monkeysphere')) -def monkeysphere_publish_key(session_browser, key_type, domain): - system.monkeysphere_publish_key(session_browser, key_type.lower(), domain) - - -@when(parsers.parse('I wait for {seconds} seconds')) -def sleep_for(seconds): - seconds = int(seconds) - time.sleep(seconds) - - -@when(parsers.parse('I open the main page')) -def open_main_page(session_browser): - system.open_main_page(session_browser) - - -@then(parsers.parse('the main page should be shown')) -def main_page_is_shown(session_browser): - assert (session_browser.url.endswith('/plinth/')) - - -@given(parsers.parse('the network device is in the {zone:w} firewall zone')) -def networks_set_firewall_zone(session_browser, zone): - system.networks_set_firewall_zone(session_browser, zone) - - -@then('the root disk should be shown') -def storage_root_disk_is_shown(session_browser): - assert system.storage_is_root_disk_shown(session_browser) diff --git a/plinth/tests/functional/support/__init__.py b/plinth/tests/functional/support/__init__.py deleted file mode 100644 index 9532249c2..000000000 --- a/plinth/tests/functional/support/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -import configparser -import os -import pathlib - -config = configparser.ConfigParser() -config.read(pathlib.Path(__file__).parent.with_name('config.ini')) - -config['DEFAULT']['url'] = os.environ.get('FREEDOMBOX_URL', - config['DEFAULT']['url']) -config['DEFAULT']['samba_port'] = os.environ.get( - 'FREEDOMBOX_SAMBA_PORT', config['DEFAULT']['samba_port']) diff --git a/plinth/tests/functional/support/application.py b/plinth/tests/functional/support/application.py deleted file mode 100644 index 9fbb1e2c4..000000000 --- a/plinth/tests/functional/support/application.py +++ /dev/null @@ -1,752 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -import contextlib -import os -import random -import shutil -import string -import subprocess -import tempfile -import urllib -from time import sleep - -import requests -import splinter - -from . import config, interface, site -from .interface import submit -from .service import eventually, wait_for_page_update - -# unlisted apps just use the app_name as module name -app_module = { - 'ntp': 'datetime', - 'wiki': 'ikiwiki', - 'tt-rss': 'ttrss', -} - -app_checkbox_id = { - 'tor': 'id_tor-enabled', -} - -default_url = config['DEFAULT']['url'] - -apps_with_loaders = ['tor'] - - -def get_app_module(app_name): - module = app_name - if app_name in app_module: - module = app_module[app_name] - return module - - -def _find_install_button(browser, app_name): - interface.nav_to_module(browser, get_app_module(app_name)) - return browser.find_by_css('.form-install input[type=submit]') - - -def install(browser, app_name): - install_button = _find_install_button(browser, app_name) - - def install_in_progress(): - selectors = [ - '.install-state-' + state - for state in ['pre', 'post', 'installing'] - ] - return any( - browser.is_element_present_by_css(selector) - for selector in selectors) - - def is_server_restarting(): - return browser.is_element_present_by_css('.neterror') - - def wait_for_install(): - if install_in_progress(): - sleep(1) - elif is_server_restarting(): - sleep(1) - browser.visit(browser.url) - else: - return - wait_for_install() - - if install_button: - install_button.click() - wait_for_install() - # sleep(2) # XXX This shouldn't be required. - - -def is_installed(browser, app_name): - install_button = _find_install_button(browser, app_name) - return not bool(install_button) - - -def _change_app_status(browser, app_name, change_status_to='enabled'): - """Enable or disable application.""" - button = browser.find_by_css('button[name="app_enable_disable_button"]') - - if button: - should_enable_field = browser.find_by_id('id_should_enable') - if (should_enable_field.value == 'False' - and change_status_to == 'disabled') or ( - should_enable_field.value == 'True' - and change_status_to == 'enabled'): - interface.submit(browser, element=button) - else: - checkbox_id = app_checkbox_id[app_name] - _change_status(browser, app_name, checkbox_id, change_status_to) - - if app_name in apps_with_loaders: - wait_for_config_update(browser, app_name) - - -def _change_status(browser, app_name, checkbox_id, change_status_to='enabled'): - """Change checkbox status.""" - checkbox = browser.find_by_id(checkbox_id) - checkbox.check() if change_status_to == 'enabled' else checkbox.uncheck() - interface.submit(browser, form_class='form-configuration') - - if app_name in apps_with_loaders: - wait_for_config_update(browser, app_name) - - -def enable(browser, app_name): - interface.nav_to_module(browser, get_app_module(app_name)) - _change_app_status(browser, app_name, 'enabled') - - -def disable(browser, app_name): - interface.nav_to_module(browser, get_app_module(app_name)) - _change_app_status(browser, app_name, 'disabled') - - -def can_be_disabled(browser, app_name): - """Return whether the application can be disabled.""" - interface.nav_to_module(browser, get_app_module(app_name)) - button = browser.find_by_css('button[name="app_enable_disable_button"]') - return bool(button) - - -def wait_for_config_update(browser, app_name): - while browser.is_element_present_by_css('.running-status.loading'): - sleep(0.1) - - -def _download_file(browser, url): - """Return file contents after downloading a URL.""" - cookies = browser.cookies.all() - response = requests.get(url, cookies=cookies, verify=False) - if response.status_code != 200: - raise Exception('URL download failed') - - return response.content - - -def select_domain_name(browser, app_name, domain_name): - browser.visit('{}/plinth/apps/{}/setup/'.format(default_url, app_name)) - drop_down = browser.find_by_id('id_domain_name') - drop_down.select(domain_name) - interface.submit(browser, form_class='form-configuration') - - -def configure_shadowsocks(browser, server, password): - """Configure shadowsocks client with given server details.""" - browser.visit('{}/plinth/apps/shadowsocks/'.format(default_url)) - browser.find_by_id('id_server').fill(server) - browser.find_by_id('id_password').fill(password) - interface.submit(browser, form_class='form-configuration') - - -def shadowsocks_get_configuration(browser): - """Return the server and password currently configured in shadowsocks.""" - browser.visit('{}/plinth/apps/shadowsocks/'.format(default_url)) - server = browser.find_by_id('id_server').value - password = browser.find_by_id('id_password').value - return server, password - - -def modify_max_file_size(browser, size): - """Change the maximum file size of coquelicot to the given value""" - browser.visit('{}/plinth/apps/coquelicot/'.format(default_url)) - browser.find_by_id('id_max_file_size').fill(size) - interface.submit(browser, form_class='form-configuration') - - -def get_max_file_size(browser): - """Get the maximum file size of coquelicot""" - browser.visit('{}/plinth/apps/coquelicot/'.format(default_url)) - return int(browser.find_by_id('id_max_file_size').value) - - -def modify_upload_password(browser, password): - """Change the upload password for coquelicot to the given value""" - browser.visit('{}/plinth/apps/coquelicot/'.format(default_url)) - browser.find_by_id('id_upload_password').fill(password) - interface.submit(browser, form_class='form-configuration') - - -# Sharing app helper functions - - -def remove_share(browser, name): - """Remove a share in sharing app.""" - try: - share_row = get_share(browser, name) - except splinter.exceptions.ElementDoesNotExist: - pass - else: - share_row.find_by_css('.share-remove')[0].click() - - -def add_share(browser, name, path, group): - """Add a share in sharing app.""" - browser.visit('{}/plinth/apps/sharing/add/'.format(default_url)) - browser.fill('sharing-name', name) - browser.fill('sharing-path', path) - browser.find_by_css( - '#id_sharing-groups input[value="{}"]'.format(group)).check() - submit(browser) - - -def edit_share(browser, old_name, new_name, path, group): - """Edit a share in sharing app.""" - row = get_share(browser, old_name) - with wait_for_page_update(browser): - row.find_by_css('.share-edit')[0].click() - browser.fill('sharing-name', new_name) - browser.fill('sharing-path', path) - browser.find_by_css('#id_sharing-groups input').uncheck() - browser.find_by_css( - '#id_sharing-groups input[value="{}"]'.format(group)).check() - submit(browser) - - -def get_share(browser, name): - """Return the row for a given share.""" - browser.visit('{}/plinth/apps/sharing/'.format(default_url)) - return browser.find_by_id('share-{}'.format(name))[0] - - -def verify_share(browser, name, path, group): - """Verfiy that a share exists in list of shares.""" - href = '{}/share/{}'.format(default_url, name) - url = '/share/{}'.format(name) - row = get_share(browser, name) - assert row.find_by_css('.share-name')[0].text == name - assert row.find_by_css('.share-path')[0].text == path - assert row.find_by_css('.share-url a')[0]['href'] == href - assert row.find_by_css('.share-url a')[0].text == url - assert row.find_by_css('.share-groups')[0].text == group - - -def access_share(browser, name): - """Visit a share and see if it is accessible.""" - row = get_share(browser, name) - url = row.find_by_css('.share-url a')[0]['href'] - browser.visit(url) - assert '/share/{}'.format(name) in browser.title - - -def make_share_public(browser, name): - """Make share publicly accessible.""" - row = get_share(browser, name) - with wait_for_page_update(browser): - row.find_by_css('.share-edit')[0].click() - browser.find_by_id('id_sharing-is_public').check() - interface.submit(browser) - - -def verify_nonexistant_share(browser, name): - """Verify that given URL for a given share name is a 404.""" - url = '{}/share/{}'.format(default_url, name) - browser.visit(url) - assert '404' in browser.title - - -def verify_inaccessible_share(browser, name): - """Verify that given URL for a given share name denies permission.""" - url = '{}/share/{}'.format(default_url, name) - browser.visit(url) - eventually(lambda: '/plinth' in browser.url, args=[]) - - -def enable_mediawiki_public_registrations(browser): - """Enable public registrations in MediaWiki.""" - interface.nav_to_module(browser, 'mediawiki') - _change_status(browser, 'mediawiki', 'id_enable_public_registrations', - 'enabled') - - -def disable_mediawiki_public_registrations(browser): - """Enable public registrations in MediaWiki.""" - interface.nav_to_module(browser, 'mediawiki') - _change_status(browser, 'mediawiki', 'id_enable_public_registrations', - 'disabled') - - -def enable_mediawiki_private_mode(browser): - """Enable public registrations in MediaWiki.""" - interface.nav_to_module(browser, 'mediawiki') - _change_status(browser, 'mediawiki', 'id_enable_private_mode', 'enabled') - - -def disable_mediawiki_private_mode(browser): - """Enable public registrations in MediaWiki.""" - interface.nav_to_module(browser, 'mediawiki') - _change_status(browser, 'mediawiki', 'id_enable_private_mode', 'disabled') - - -def set_mediawiki_admin_password(browser, password): - """Set a password for the MediaWiki user called admin.""" - interface.nav_to_module(browser, 'mediawiki') - browser.find_by_id('id_password').fill(password) - interface.submit(browser, form_class='form-configuration') - - -def enable_ejabberd_message_archive_management(browser): - """Enable Message Archive Management in Ejabberd.""" - interface.nav_to_module(browser, 'ejabberd') - _change_status(browser, 'ejabberd', 'id_MAM_enabled', 'enabled') - - -def disable_ejabberd_message_archive_management(browser): - """Enable Message Archive Management in Ejabberd.""" - interface.nav_to_module(browser, 'ejabberd') - _change_status(browser, 'ejabberd', 'id_MAM_enabled', 'disabled') - - -def ejabberd_add_contact(browser): - """Add a contact to Ejabberd user's roster.""" - site.jsxc_add_contact(browser) - - -def ejabberd_delete_contact(browser): - """Delete the contact from Ejabberd user's roster.""" - site.jsxc_delete_contact(browser) - - -def ejabberd_has_contact(browser): - """Check whether the contact is in Ejabberd user's roster.""" - return eventually(site.jsxc_has_contact, [browser]) - - -def gitweb_create_repo(browser, repo, access=None, ok_if_exists=False): - """Create repository.""" - if not gitweb_repo_exists(browser, repo, access): - gitweb_delete_repo(browser, repo, ignore_missing=True) - browser.find_link_by_href('/plinth/apps/gitweb/create/').first.click() - browser.find_by_id('id_gitweb-name').fill(repo) - if access == 'private': - browser.find_by_id('id_gitweb-is_private').check() - elif access == 'public': - browser.find_by_id('id_gitweb-is_private').uncheck() - submit(browser) - elif not ok_if_exists: - assert False, 'Repo already exists.' - - -def gitweb_delete_repo(browser, repo, ignore_missing=False): - """Delete repository.""" - interface.nav_to_module(browser, 'gitweb') - delete_link = browser.find_link_by_href( - '/plinth/apps/gitweb/{}/delete/'.format(repo)) - if delete_link or not ignore_missing: - delete_link.first.click() - submit(browser) - - -def gitweb_edit_repo_metadata(browser, repo, metadata): - """Set repository metadata.""" - interface.nav_to_module(browser, 'gitweb') - browser.find_link_by_href( - '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click() - if 'name' in metadata: - browser.find_by_id('id_gitweb-name').fill(metadata['name']) - if 'description' in metadata: - browser.find_by_id('id_gitweb-description').fill( - metadata['description']) - if 'owner' in metadata: - browser.find_by_id('id_gitweb-owner').fill(metadata['owner']) - if 'access' in metadata: - if metadata['access'] == 'private': - browser.find_by_id('id_gitweb-is_private').check() - else: - browser.find_by_id('id_gitweb-is_private').uncheck() - submit(browser) - - -def gitweb_get_repo_metadata(browser, repo): - """Get repository metadata.""" - interface.nav_to_module(browser, 'gitweb') - browser.find_link_by_href( - '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click() - metadata = {} - for item in ['name', 'description', 'owner']: - metadata[item] = browser.find_by_id('id_gitweb-' + item).value - if browser.find_by_id('id_gitweb-is_private').value: - metadata['access'] = 'private' - else: - metadata['access'] = 'public' - return metadata - - -def _gitweb_get_repo_url(repo, with_auth): - """"Get repository URL""" - scheme = 'http' - if default_url.startswith('https://'): - scheme = 'https' - url = default_url.split('://')[1] if '://' in default_url else default_url - password = 'gitweb_wrong_password' - if with_auth: - password = config['DEFAULT']['password'] - - return '{0}://{1}:{2}@{3}/gitweb/{4}'.format(scheme, - config['DEFAULT']['username'], - password, url, repo) - - -@contextlib.contextmanager -def _gitweb_temp_directory(): - """Create temporary directory""" - name = tempfile.mkdtemp(prefix='plinth_test_gitweb_') - yield name - shutil.rmtree(name) - - -def _gitweb_git_command_is_successful(command, cwd): - """Check if a command runs successfully or gives authentication error""" - process = subprocess.run(command, capture_output=True, cwd=cwd) - if process.returncode != 0: - if 'Authentication failed' in process.stderr.decode(): - return False - print(process.stdout.decode()) - # raise exception - process.check_returncode() - return True - - -def gitweb_repo_exists(browser, repo, access=None): - """Check whether the repository exists.""" - interface.nav_to_module(browser, 'gitweb') - links_found = browser.find_link_by_href('/gitweb/{}.git'.format(repo)) - access_matches = True - if links_found and access: - parent = links_found.first.find_by_xpath('..').first - private_icon = parent.find_by_css('.repo-private-icon') - if access == 'private': - access_matches = True if private_icon else False - if access == 'public': - access_matches = True if not private_icon else False - return bool(links_found) and access_matches - - -def gitweb_repo_is_readable(repo, with_auth=False, url_git_extension=False): - """Check if a git repo is readable with git client.""" - url = _gitweb_get_repo_url(repo, with_auth) - if url_git_extension: - url = url + '.git' - git_command = ['git', 'clone', '-c', 'http.sslverify=false', url] - with _gitweb_temp_directory() as cwd: - return _gitweb_git_command_is_successful(git_command, cwd) - - -def gitweb_repo_is_writable(repo, with_auth=False, url_git_extension=False): - """Check if a git repo is writable with git client.""" - url = _gitweb_get_repo_url(repo, with_auth) - if url_git_extension: - url = url + '.git' - - with _gitweb_temp_directory() as cwd: - subprocess.run(['mkdir', 'test-project'], check=True, cwd=cwd) - cwd = os.path.join(cwd, 'test-project') - prepare_git_repo_commands = [ - 'git init -q', 'git config http.sslVerify false', - 'git -c "user.name=Tester" -c "user.email=tester" ' - 'commit -q --allow-empty -m "test"' - ] - for command in prepare_git_repo_commands: - subprocess.run(command, shell=True, check=True, cwd=cwd) - git_push_command = ['git', 'push', '-qf', url, 'master'] - - return _gitweb_git_command_is_successful(git_push_command, cwd) - - -def gitweb_set_repo_access(browser, repo, access): - """Set repository as public or private.""" - interface.nav_to_module(browser, 'gitweb') - browser.find_link_by_href( - '/plinth/apps/gitweb/{}/edit/'.format(repo)).first.click() - if access == 'private': - browser.find_by_id('id_gitweb-is_private').check() - else: - browser.find_by_id('id_gitweb-is_private').uncheck() - submit(browser) - - -def gitweb_set_all_repos_private(browser): - """Set all repositories private""" - interface.nav_to_module(browser, 'gitweb') - public_repos = [] - for element in browser.find_by_css('#gitweb-repo-list .list-group-item'): - if not element.find_by_css('.repo-private-icon'): - repo = element.find_by_css('.repo-label').first.text - public_repos.append(repo) - for repo in public_repos: - gitweb_set_repo_access(browser, repo, 'private') - - -def gitweb_site_repo_exists(browser, repo): - """Check whether the repository exists on Gitweb site.""" - browser.visit('{}/gitweb'.format(default_url)) - return browser.find_by_css('a[href="/gitweb/{0}.git"]'.format(repo)) - - -def ikiwiki_create_wiki_if_needed(browser): - """Create wiki if it does not exist.""" - interface.nav_to_module(browser, 'ikiwiki') - wiki = browser.find_link_by_href('/ikiwiki/wiki') - if not wiki: - browser.find_link_by_href('/plinth/apps/ikiwiki/create/').first.click() - browser.find_by_id('id_ikiwiki-name').fill('wiki') - browser.find_by_id('id_ikiwiki-admin_name').fill( - config['DEFAULT']['username']) - browser.find_by_id('id_ikiwiki-admin_password').fill( - config['DEFAULT']['password']) - submit(browser) - - -def ikiwiki_delete_wiki(browser): - """Delete wiki.""" - interface.nav_to_module(browser, 'ikiwiki') - browser.find_link_by_href( - '/plinth/apps/ikiwiki/wiki/delete/').first.click() - submit(browser) - - -def ikiwiki_wiki_exists(browser): - """Check whether the wiki exists.""" - interface.nav_to_module(browser, 'ikiwiki') - wiki = browser.find_link_by_href('/ikiwiki/wiki') - return bool(wiki) - - -def time_zone_set(browser, time_zone): - """Set the system time zone.""" - interface.nav_to_module(browser, 'datetime') - browser.select('time_zone', time_zone) - interface.submit(browser, form_class='form-configuration') - - -def time_zone_get(browser): - """Set the system time zone.""" - interface.nav_to_module(browser, 'datetime') - return browser.find_by_name('time_zone').first.value - - -_TOR_FEATURE_TO_ELEMENT = { - 'relay': 'tor-relay_enabled', - 'bridge-relay': 'tor-bridge_relay_enabled', - 'hidden-services': 'tor-hs_enabled', - 'software': 'tor-apt_transport_tor_enabled' -} - - -def tor_feature_enable(browser, feature, should_enable): - """Enable/disable a Tor feature.""" - if not isinstance(should_enable, bool): - should_enable = should_enable in ('enable', 'enabled') - - element_name = _TOR_FEATURE_TO_ELEMENT[feature] - interface.nav_to_module(browser, 'tor') - checkbox_element = browser.find_by_name(element_name).first - if should_enable == checkbox_element.checked: - return - - if should_enable: - if feature == 'bridge-relay': - browser.find_by_name('tor-relay_enabled').first.check() - - checkbox_element.check() - else: - checkbox_element.uncheck() - - interface.submit(browser, form_class='form-configuration') - wait_for_config_update(browser, 'tor') - - -def tor_assert_feature_enabled(browser, feature, enabled): - """Assert whether Tor relay is enabled or disabled.""" - if not isinstance(enabled, bool): - enabled = enabled in ('enable', 'enabled') - - element_name = _TOR_FEATURE_TO_ELEMENT[feature] - interface.nav_to_module(browser, 'tor') - assert browser.find_by_name(element_name).first.checked == enabled - - -def tor_get_relay_ports(browser): - """Return the list of ports shown in the relay table.""" - interface.nav_to_module(browser, 'tor') - return [ - port_name.text - for port_name in browser.find_by_css('.tor-relay-port-name') - ] - - -def tor_assert_hidden_services(browser): - """Assert that hidden service information is shown.""" - interface.nav_to_module(browser, 'tor') - assert browser.find_by_css('.tor-hs .tor-hs-hostname') - - -def tahoe_get_introducer(browser, domain, introducer_type): - """Return an introducer element with a given type from tahoe-lafs.""" - interface.nav_to_module(browser, 'tahoe') - css_class = '.{}-introducers .introducer-furl'.format(introducer_type) - for furl in browser.find_by_css(css_class): - if domain in furl.text: - return furl.parent - - return None - - -def tahoe_add_introducer(browser, domain): - """Add a new introducer into tahoe-lafs.""" - interface.nav_to_module(browser, 'tahoe') - - furl = 'pb://ewe4zdz6kxn7xhuvc7izj2da2gpbgeir@tcp:{}:3456/' \ - 'fko4ivfwgqvybppwar3uehkx6spaaou7'.format(domain) - browser.fill('pet_name', 'testintroducer') - browser.fill('furl', furl) - submit(browser, form_class='form-add-introducer') - - -def tahoe_remove_introducer(browser, domain): - """Remove an introducer from tahoe-lafs.""" - introducer = tahoe_get_introducer(browser, domain, 'connected') - submit(browser, element=introducer.find_by_css('.form-remove')) - - -def radicale_get_access_rights(browser): - access_rights_types = ['owner_only', 'owner_write', 'authenticated'] - interface.nav_to_module(browser, 'radicale') - for access_rights_type in access_rights_types: - if browser.find_by_value(access_rights_type).checked: - return access_rights_type - - -def radicale_set_access_rights(browser, access_rights_type): - interface.nav_to_module(browser, 'radicale') - browser.choose('access_rights', access_rights_type) - interface.submit(browser, form_class='form-configuration') - - -def openvpn_setup(browser): - """Setup the OpenVPN application after installation.""" - interface.nav_to_module(browser, 'openvpn') - setup_form = browser.find_by_css('.form-setup') - if not setup_form: - return - - submit(browser, form_class='form-setup') - wait_for_config_update(browser, 'openvpn') - - -def openvpn_download_profile(browser): - """Download the current user's profile into a file and return path.""" - interface.nav_to_module(browser, 'openvpn') - url = browser.find_by_css('.form-profile')['action'] - return _download_file(browser, url) - - -def samba_set_share(browser, share_type, status='enabled'): - """Enable or disable samba share.""" - disk_name = 'disk' - share_type_name = '{0}_share'.format(share_type) - interface.nav_to_module(browser, 'samba') - for elem in browser.find_by_tag('td'): - if elem.text == disk_name: - share_form = elem.find_by_xpath('(..//*)[2]/form').first - share_btn = share_form.find_by_name(share_type_name).first - if status == 'enabled' and share_btn['value'] == 'enable': - share_btn.click() - elif status == 'disabled' and share_btn['value'] == 'disable': - share_btn.click() - break - - -def _samba_write_to_share(share_type, as_guest=False): - """Write to the samba share, return output messages as string.""" - disk_name = 'disk' - if share_type == 'open': - share_name = disk_name - else: - share_name = '{0}_{1}'.format(disk_name, share_type) - hostname = urllib.parse.urlparse(default_url).hostname - servicename = '\\\\{0}\\{1}'.format(hostname, share_name) - directory = '_plinth-test_{0}'.format(''.join( - random.SystemRandom().choices(string.ascii_letters, k=8))) - port = config['DEFAULT']['samba_port'] - - smb_command = ['smbclient', '-W', 'WORKGROUP', '-p', port] - if as_guest: - smb_command += ['-N'] - else: - smb_command += [ - '-U', '{0}%{1}'.format(config['DEFAULT']['username'], - config['DEFAULT']['password']) - ] - smb_command += [ - servicename, '-c', 'mkdir {0}; rmdir {0}'.format(directory) - ] - - return subprocess.check_output(smb_command).decode() - - -def samba_assert_share_is_writable(share_type, as_guest=False): - """Assert that samba share is writable.""" - output = _samba_write_to_share(share_type, as_guest=False) - - assert not output, output - - -def samba_assert_share_is_not_accessible(share_type, as_guest=False): - """Assert that samba share is not accessible.""" - try: - _samba_write_to_share(share_type, as_guest) - except subprocess.CalledProcessError as err: - err_output = err.output.decode() - assert 'NT_STATUS_ACCESS_DENIED' in err_output, err_output - else: - assert False, 'Can access the share.' - - -def samba_assert_share_is_not_available(share_type): - """Assert that samba share is not accessible.""" - try: - _samba_write_to_share(share_type) - except subprocess.CalledProcessError as err: - err_output = err.output.decode() - assert 'NT_STATUS_BAD_NETWORK_NAME' in err_output, err_output - else: - assert False, 'Can access the share.' - - -def searx_enable_public_access(browser): - """Enable Public Access in SearX""" - interface.nav_to_module(browser, 'searx') - browser.find_by_id('id_public_access').check() - interface.submit(browser, form_class='form-configuration') - - -def searx_disable_public_access(browser): - """Enable Public Access in SearX""" - interface.nav_to_module(browser, 'searx') - browser.find_by_id('id_public_access').uncheck() - interface.submit(browser, form_class='form-configuration') - - -def find_on_front_page(browser, app_name): - browser.visit(default_url) - shortcuts = browser.find_link_by_href(f'/{app_name}/') - return shortcuts diff --git a/plinth/tests/functional/support/interface.py b/plinth/tests/functional/support/interface.py deleted file mode 100644 index 5e9f43339..000000000 --- a/plinth/tests/functional/support/interface.py +++ /dev/null @@ -1,150 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -import logging -import random -import tempfile - -import requests - -from . import config -from .service import wait_for_page_update - -sys_modules = [ - 'avahi', 'backups', 'bind', 'cockpit', 'config', 'datetime', 'diagnostics', - 'dynamicdns', 'firewall', 'letsencrypt', 'monkeysphere', 'names', - 'networks', 'pagekite', 'performance', 'power', 'security', 'snapshot', - 'ssh', 'storage', 'upgrades', 'users' -] - -default_url = config['DEFAULT']['url'] - - -def login(browser, url, username, password): - - # XXX: Find a way to remove the hardcoded jsxc URL - if '/plinth/' not in browser.url or '/jsxc/jsxc' in browser.url: - browser.visit(url) - - apps_link = browser.find_link_by_href('/plinth/apps/') - if len(apps_link): - return - - login_button = browser.find_link_by_href('/plinth/accounts/login/') - if login_button: - login_button.first.click() - if login_button: - browser.fill('username', username) - browser.fill('password', password) - submit(browser) - else: - browser.visit(default_url + '/plinth/firstboot/welcome') - submit(browser) # click the "Start Setup" button - create_admin_account(browser, username, password) - if '/network-topology-first-boot' in browser.url: - submit(browser, element=browser.find_by_name('skip')[0]) - - if '/internet-connection-type' in browser.url: - submit(browser, element=browser.find_by_name('skip')[0]) - - -def is_login_prompt(browser): - return all( - [browser.find_by_id('id_username'), - browser.find_by_id('id_password')]) - - -def nav_to_module(browser, module): - sys_or_apps = 'sys' if module in sys_modules else 'apps' - required_url = default_url + f'/plinth/{sys_or_apps}/{module}/' - if browser.url != required_url: - browser.visit(required_url) - - -def create_user(browser, name, password): - nav_to_module(browser, 'users') - with wait_for_page_update(browser): - browser.find_link_by_href('/plinth/sys/users/create/').first.click() - browser.find_by_id('id_username').fill(name) - browser.find_by_id('id_password1').fill(password) - browser.find_by_id('id_password2').fill(password) - submit(browser) - - -def rename_user(browser, old_name, new_name): - nav_to_module(browser, 'users') - with wait_for_page_update(browser): - browser.find_link_by_href('/plinth/sys/users/' + old_name + - '/edit/').first.click() - browser.find_by_id('id_username').fill(new_name) - submit(browser) - - -def delete_user(browser, name): - nav_to_module(browser, 'users') - delete_link = browser.find_link_by_href('/plinth/sys/users/' + name + - '/delete/') - if delete_link: - with wait_for_page_update(browser): - delete_link.first.click() - submit(browser) - - -def is_user(browser, name): - nav_to_module(browser, 'users') - edit_link = browser.find_link_by_href('/plinth/sys/users/' + name + - '/edit/') - return bool(edit_link) - - -def create_admin_account(browser, username, password): - browser.find_by_id('id_username').fill(username) - browser.find_by_id('id_password1').fill(password) - browser.find_by_id('id_password2').fill(password) - submit(browser) - - -def submit(browser, element=None, form_class=None, expected_url=None): - with wait_for_page_update(browser, expected_url=expected_url): - if element: - element.click() - elif form_class: - browser.find_by_css( - '.{} input[type=submit]'.format(form_class)).click() - else: - browser.find_by_css('input[type=submit]').click() - - -def create_sample_local_file(): - """Create a sample file for upload using browser.""" - contents = bytearray(random.getrandbits(8) for _ in range(64)) - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - temp_file.write(contents) - - return temp_file.name, contents - - -def download_file(url): - """Download a file to disk given a URL.""" - with tempfile.NamedTemporaryFile(delete=False) as temp_file: - logging.captureWarnings(True) - request = requests.get(url, verify=False) - logging.captureWarnings(False) - temp_file.write(request.content) - - return temp_file.name - - -def compare_files(file1, file2): - """Assert that the contents of two files are the same.""" - file1_contents = open(file1, 'rb').read() - file2_contents = open(file2, 'rb').read() - - assert file1_contents == file2_contents - - -def go_to_status_logs(browser): - browser.visit(default_url + '/plinth/help/status-log/') - - -def are_status_logs_shown(browser): - return browser.is_text_present('Logs begin') diff --git a/plinth/tests/functional/support/service.py b/plinth/tests/functional/support/service.py deleted file mode 100644 index 02b5d83b8..000000000 --- a/plinth/tests/functional/support/service.py +++ /dev/null @@ -1,79 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -import time -from contextlib import contextmanager - -from selenium.common.exceptions import StaleElementReferenceException -from selenium.webdriver.support.ui import WebDriverWait - -from . import interface - -# unlisted services just use the service_name as module name -service_module = { - 'ntp': 'datetime', -} - - -def get_service_module(service_name): - module = service_name - if service_name in service_module: - module = service_module[service_name] - return module - - -def is_running(browser, service_name): - interface.nav_to_module(browser, get_service_module(service_name)) - return len(browser.find_by_id('service-not-running')) == 0 - - -def is_not_running(browser, service_name): - interface.nav_to_module(browser, get_service_module(service_name)) - return len(browser.find_by_id('service-not-running')) != 0 - - -def eventually(function, args=[], timeout=30): - """Execute a function returning a boolean expression till it returns - True or a timeout is reached""" - end_time = time.time() + timeout - current_time = time.time() - while current_time < end_time: - if function(*args): - return True - - time.sleep(0.1) - current_time = time.time() - - return False - - -@contextmanager -def wait_for_page_update(browser, timeout=300, expected_url=None): - page_body = browser.find_by_tag('body').first - yield - WebDriverWait(browser, timeout).until(page_loaded(page_body, expected_url)) - - -class page_loaded(): - """ - Wait until a page (re)loaded. - - - element: Wait until this element gets stale - - expected_url (optional): Wait for the URL to become . - This can be necessary to wait for a redirect to finish. - """ - - def __init__(self, element, expected_url=None): - self.element = element - self.expected_url = expected_url - - def __call__(self, driver): - is_stale = False - try: - self.element.has_class('whatever_class') - except StaleElementReferenceException: - if self.expected_url is None: - is_stale = True - else: - if driver.url.endswith(self.expected_url): - is_stale = True - return is_stale diff --git a/plinth/tests/functional/support/site.py b/plinth/tests/functional/support/site.py deleted file mode 100644 index bd8ec80fc..000000000 --- a/plinth/tests/functional/support/site.py +++ /dev/null @@ -1,589 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -import logging -import os -import pathlib -import time - -import requests -from selenium.webdriver.common.action_chains import ActionChains -from selenium.webdriver.common.keys import Keys - -from . import application, config, interface, system -from .service import eventually, wait_for_page_update - -# unlisted sites just use '/' + site_name as url -site_url = { - 'wiki': '/ikiwiki', - 'jsxc': '/plinth/apps/jsxc/jsxc/', - 'cockpit': '/_cockpit/', - 'syncthing': '/syncthing/', -} - - -def get_site_url(site_name): - if site_name.startswith('share'): - site_name = site_name.replace('_', '/') - url = '/' + site_name - if site_name in site_url: - url = site_url[site_name] - return url - - -def is_available(browser, site_name): - url_to_visit = config['DEFAULT']['url'] + get_site_url(site_name) - browser.visit(url_to_visit) - time.sleep(3) - browser.reload() - not_404 = '404' not in browser.title - # The site might have a default path after the sitename, - # e.g /mediawiki/Main_Page - no_redirect = browser.url.startswith(url_to_visit.strip('/')) - return not_404 and no_redirect - - -def access_url(browser, site_name): - browser.visit(config['DEFAULT']['url'] + get_site_url(site_name)) - - -def verify_coquelicot_upload_password(browser, password): - browser.visit(config['DEFAULT']['url'] + '/coquelicot') - # ensure the password form is scrolled into view - browser.execute_script('window.scrollTo(100, 0)') - browser.find_by_id('upload_password').fill(password) - actions = ActionChains(browser.driver) - actions.send_keys(Keys.RETURN) - actions.perform() - assert eventually(browser.is_element_present_by_css, - args=['div[style*="display: none;"]']) - - -def upload_file_to_coquelicot(browser, file_path, password): - """Upload a local file from disk to coquelicot.""" - verify_coquelicot_upload_password(browser, password) - browser.attach_file('file', file_path) - interface.submit(browser) - assert eventually(browser.is_element_present_by_css, - args=['#content .url']) - url_textarea = browser.find_by_css('#content .url textarea').first - return url_textarea.value - - -def verify_mediawiki_create_account_link(browser): - browser.visit(config['DEFAULT']['url'] + - '/mediawiki/index.php/Special:CreateAccount') - assert eventually(browser.is_element_present_by_id, - args=['wpCreateaccount']) - - -def verify_mediawiki_no_create_account_link(browser): - browser.visit(config['DEFAULT']['url'] + - '/mediawiki/index.php/Special:CreateAccount') - assert eventually(browser.is_element_not_present_by_id, - args=['wpCreateaccount']) - - -def verify_mediawiki_anonymous_reads_edits_link(browser): - browser.visit(config['DEFAULT']['url'] + '/mediawiki') - assert eventually(browser.is_element_present_by_id, args=['ca-nstab-main']) - - -def verify_mediawiki_no_anonymous_reads_edits_link(browser): - browser.visit(config['DEFAULT']['url'] + '/mediawiki') - assert eventually(browser.is_element_not_present_by_id, - args=['ca-nstab-main']) - assert eventually(browser.is_element_present_by_id, - args=['ca-nstab-special']) - - -def _login_to_mediawiki(browser, username, password): - browser.visit(config['DEFAULT']['url'] + - '/mediawiki/index.php?title=Special:Login') - browser.find_by_id('wpName1').fill(username) - browser.find_by_id('wpPassword1').fill(password) - with wait_for_page_update(browser): - browser.find_by_id('wpLoginAttempt').click() - - -def login_to_mediawiki_with_credentials(browser, username, password): - _login_to_mediawiki(browser, username, password) - # Had to put it in the same step because sessions don't - # persist between steps - assert eventually(browser.is_element_present_by_id, args=['t-upload']) - - -def upload_image_mediawiki(browser, username, password, image): - """Upload an image to MediaWiki. Idempotent.""" - browser.visit(config['DEFAULT']['url'] + '/mediawiki') - _login_to_mediawiki(browser, username, password) - - # Upload file - browser.visit(config['DEFAULT']['url'] + '/mediawiki/Special:Upload') - file_path = pathlib.Path(__file__).parent - file_path /= '../../static/themes/default/img/' + image - browser.attach_file('wpUploadFile', str(file_path.resolve())) - interface.submit(browser, element=browser.find_by_name('wpUpload')[0]) - - -def get_number_of_uploaded_images_in_mediawiki(browser): - browser.visit(config['DEFAULT']['url'] + '/mediawiki/Special:ListFiles') - return len(browser.find_by_css('.TablePager_col_img_timestamp')) - - -def get_uploaded_image_in_mediawiki(browser, image): - browser.visit(config['DEFAULT']['url'] + '/mediawiki/Special:ListFiles') - elements = browser.find_link_by_partial_href(image) - return elements[0].value - - -def mediawiki_delete_main_page(browser): - """Delete the mediawiki main page.""" - _login_to_mediawiki(browser, 'admin', 'whatever123') - browser.visit( - '{}/mediawiki/index.php?title=Main_Page&action=delete'.format( - interface.default_url)) - with wait_for_page_update(browser): - browser.find_by_id('wpConfirmB').first.click() - - -def mediawiki_has_main_page(browser): - """Check if mediawiki main page exists.""" - return eventually(_mediawiki_has_main_page, [browser]) - - -def _mediawiki_has_main_page(browser): - """Check if mediawiki main page exists.""" - browser.visit('{}/mediawiki/Main_Page'.format(interface.default_url)) - content = browser.find_by_id('mw-content-text').first - return 'This page has been deleted.' not in content.text - - -def jsxc_login(browser): - """Login to JSXC.""" - access_url(browser, 'jsxc') - browser.find_by_id('jsxc-username').fill(config['DEFAULT']['username']) - browser.find_by_id('jsxc-password').fill(config['DEFAULT']['password']) - browser.find_by_id('jsxc-submit').click() - relogin = browser.find_by_text('relogin') - if relogin: - relogin.first.click() - browser.find_by_id('jsxc_username').fill(config['DEFAULT']['username']) - browser.find_by_id('jsxc_password').fill(config['DEFAULT']['password']) - browser.find_by_text('Connect').first.click() - - -def jsxc_add_contact(browser): - """Add a contact to JSXC user's roster.""" - system.set_domain_name(browser, 'localhost') - application.install(browser, 'jsxc') - jsxc_login(browser) - new = browser.find_by_text('new contact') - if new: # roster is empty - new.first.click() - browser.find_by_id('jsxc_username').fill('alice@localhost') - browser.find_by_text('Add').first.click() - - -def jsxc_delete_contact(browser): - """Delete the contact from JSXC user's roster.""" - jsxc_login(browser) - browser.find_by_css('div.jsxc_more').first.click() - browser.find_by_text('delete contact').first.click() - browser.find_by_text('Remove').first.click() - - -def jsxc_has_contact(browser): - """Check whether the contact is in JSXC user's roster.""" - jsxc_login(browser) - contact = browser.find_by_text('alice@localhost') - return bool(contact) - - -def _mldonkey_submit_command(browser, command): - """Submit a command to mldonkey.""" - with browser.get_iframe('commands') as commands_frame: - commands_frame.find_by_css('.txt2').fill(command) - commands_frame.find_by_css('.but2').click() - - -def mldonkey_remove_all_ed2k_files(browser): - """Remove all ed2k files from mldonkey.""" - browser.visit(config['DEFAULT']['url'] + '/mldonkey/') - _mldonkey_submit_command(browser, 'cancel all') - _mldonkey_submit_command(browser, 'confirm yes') - - -def mldonkey_upload_sample_ed2k_file(browser): - """Upload a sample ed2k file into mldonkey.""" - browser.visit(config['DEFAULT']['url'] + '/mldonkey/') - dllink_command = 'dllink ed2k://|file|foo.bar|123|' \ - '0123456789ABCDEF0123456789ABCDEF|/' - _mldonkey_submit_command(browser, dllink_command) - - -def mldonkey_get_number_of_ed2k_files(browser): - """Return the number of ed2k files currently in mldonkey.""" - browser.visit(config['DEFAULT']['url'] + '/mldonkey/') - - with browser.get_iframe('commands') as commands_frame: - commands_frame.find_by_xpath( - '//tr//td[contains(text(), "Transfers")]').click() - - with browser.get_iframe('output') as output_frame: - return len(output_frame.find_by_css('.dl-1')) + len( - output_frame.find_by_css('.dl-2')) - - -def transmission_remove_all_torrents(browser): - """Remove all torrents from transmission.""" - browser.visit(config['DEFAULT']['url'] + '/transmission') - while True: - torrents = browser.find_by_css('#torrent_list .torrent') - if not torrents: - break - - torrents.first.click() - eventually(browser.is_element_not_present_by_css, - args=['#toolbar-remove.disabled']) - browser.click_link_by_id('toolbar-remove') - eventually(browser.is_element_not_present_by_css, - args=['#dialog-container[style="display: none;"]']) - browser.click_link_by_id('dialog_confirm_button') - eventually(browser.is_element_present_by_css, - args=['#toolbar-remove.disabled']) - - -def transmission_upload_sample_torrent(browser): - """Upload a sample torrent into transmission.""" - browser.visit(config['DEFAULT']['url'] + '/transmission') - file_path = os.path.join(os.path.dirname(__file__), '..', 'data', - 'sample.torrent') - browser.click_link_by_id('toolbar-open') - eventually(browser.is_element_not_present_by_css, - args=['#upload-container[style="display: none;"]']) - browser.attach_file('torrent_files[]', [file_path]) - browser.click_link_by_id('upload_confirm_button') - eventually(browser.is_element_present_by_css, - args=['#torrent_list .torrent']) - - -def transmission_get_number_of_torrents(browser): - """Return the number torrents currently in transmission.""" - browser.visit(config['DEFAULT']['url'] + '/transmission') - return len(browser.find_by_css('#torrent_list .torrent')) - - -def _deluge_get_active_window_title(browser): - """Return the title of the currently active window in Deluge.""" - return browser.evaluate_script( - 'Ext.WindowMgr.getActive() ? Ext.WindowMgr.getActive().title : null') - - -def _deluge_ensure_logged_in(browser): - """Ensure that password dialog is answered and we can interact.""" - url = config['DEFAULT']['url'] + '/deluge' - - def service_is_available(): - if browser.is_element_present_by_xpath( - '//h1[text()="Service Unavailable"]'): - access_url(browser, 'deluge') - return False - - return True - - if browser.url != url: - browser.visit(url) - # After a backup restore, service may not be available immediately - eventually(service_is_available) - - time.sleep(1) # Wait for Ext.js application in initialize - - if _deluge_get_active_window_title(browser) != 'Login': - return - - browser.find_by_id('_password').first.fill('deluge') - _deluge_click_active_window_button(browser, 'Login') - - assert eventually( - lambda: _deluge_get_active_window_title(browser) != 'Login') - eventually(browser.is_element_not_present_by_css, - args=['#add.x-item-disabled'], timeout=0.3) - - -def _deluge_open_connection_manager(browser): - """Open the connection manager dialog if not already open.""" - title = 'Connection Manager' - if _deluge_get_active_window_title(browser) == title: - return - - browser.find_by_css('button.x-deluge-connection-manager').first.click() - eventually(lambda: _deluge_get_active_window_title(browser) == title) - - -def _deluge_ensure_connected(browser): - """Type the connection password if required and start Deluge daemon.""" - _deluge_ensure_logged_in(browser) - - # Change Default Password window appears once. - if _deluge_get_active_window_title(browser) == 'Change Default Password': - _deluge_click_active_window_button(browser, 'No') - - assert eventually(browser.is_element_not_present_by_css, - args=['#add.x-item-disabled']) - - -def deluge_remove_all_torrents(browser): - """Remove all torrents from deluge.""" - _deluge_ensure_connected(browser) - - while browser.find_by_css('#torrentGrid .torrent-name'): - browser.find_by_css('#torrentGrid .torrent-name').first.click() - - # Click remove toolbar button - browser.find_by_id('remove').first.click() - - # Remove window shows up - assert eventually(lambda: _deluge_get_active_window_title(browser) == - 'Remove Torrent') - - _deluge_click_active_window_button(browser, 'Remove With Data') - - # Remove window disappears - assert eventually(lambda: not _deluge_get_active_window_title(browser)) - - -def _deluge_get_active_window_id(browser): - """Return the ID of the currently active window.""" - return browser.evaluate_script('Ext.WindowMgr.getActive().id') - - -def _deluge_click_active_window_button(browser, button_text): - """Click an action button in the active window.""" - browser.execute_script(''' - active_window = Ext.WindowMgr.getActive(); - active_window.buttons.forEach(function (button) {{ - if (button.text == "{button_text}") - button.btnEl.dom.click() - }})'''.format(button_text=button_text)) - - -def deluge_upload_sample_torrent(browser): - """Upload a sample torrent into deluge.""" - _deluge_ensure_connected(browser) - - number_of_torrents = _deluge_get_number_of_torrents(browser) - - # Click add toolbar button - browser.find_by_id('add').first.click() - - # Add window appears - eventually( - lambda: _deluge_get_active_window_title(browser) == 'Add Torrents') - - file_path = os.path.join(os.path.dirname(__file__), '..', 'data', - 'sample.torrent') - - if browser.find_by_id('fileUploadForm'): # deluge-web 2.x - browser.attach_file('file', file_path) - else: # deluge-web 1.x - browser.find_by_css('button.x-deluge-add-file').first.click() - - # Add from file window appears - eventually(lambda: _deluge_get_active_window_title(browser) == - 'Add from File') - - # Attach file - browser.attach_file('file', file_path) - - # Click Add - _deluge_click_active_window_button(browser, 'Add') - - eventually( - lambda: _deluge_get_active_window_title(browser) == 'Add Torrents') - - # Click Add - time.sleep(1) - _deluge_click_active_window_button(browser, 'Add') - - eventually( - lambda: _deluge_get_number_of_torrents(browser) > number_of_torrents) - - -def _deluge_get_number_of_torrents(browser): - """Return the number torrents currently in deluge.""" - return len(browser.find_by_css('#torrentGrid .torrent-name')) - - -def deluge_get_number_of_torrents(browser): - """Return the number torrents currently in deluge.""" - _deluge_ensure_connected(browser) - - return _deluge_get_number_of_torrents(browser) - - -def calendar_is_available(browser): - """Return whether calendar is available at well-known URL.""" - conf = config['DEFAULT'] - url = conf['url'] + '/.well-known/caldav' - logging.captureWarnings(True) - request = requests.get(url, auth=(conf['username'], conf['password']), - verify=False) - logging.captureWarnings(False) - return request.status_code != 404 - - -def addressbook_is_available(browser): - """Return whether addressbook is available at well-known URL.""" - conf = config['DEFAULT'] - url = conf['url'] + '/.well-known/carddav' - logging.captureWarnings(True) - request = requests.get(url, auth=(conf['username'], conf['password']), - verify=False) - logging.captureWarnings(False) - return request.status_code != 404 - - -def _syncthing_load_main_interface(browser): - """Close the dialog boxes that many popup after visiting the URL.""" - access_url(browser, 'syncthing') - - def service_is_available(): - if browser.is_element_present_by_xpath( - '//h1[text()="Service Unavailable"]'): - access_url(browser, 'syncthing') - return False - - return True - - # After a backup restore, service may not be available immediately - eventually(service_is_available) - - # Wait for javascript loading process to complete - browser.execute_script(''' - document.is_ui_online = false; - var old_console_log = console.log; - console.log = function(message) { - old_console_log.apply(null, arguments); - if (message == 'UIOnline') { - document.is_ui_online = true; - console.log = old_console_log; - } - }; - ''') - eventually(lambda: browser.evaluate_script('document.is_ui_online'), - timeout=5) - - # Dismiss the Usage Reporting consent dialog - usage_reporting = browser.find_by_id('ur').first - eventually(lambda: usage_reporting.visible, timeout=2) - if usage_reporting.visible: - yes_xpath = './/button[contains(@ng-click, "declineUR")]' - usage_reporting.find_by_xpath(yes_xpath).first.click() - eventually(lambda: not usage_reporting.visible) - - -def syncthing_folder_is_present(browser, folder_name): - """Return whether a folder is present in Syncthing.""" - _syncthing_load_main_interface(browser) - folder_names = browser.find_by_css('#folders .panel-title-text span') - folder_names = [folder_name.text for folder_name in folder_names] - return folder_name in folder_names - - -def syncthing_add_folder(browser, folder_name, folder_path): - """Add a new folder to Synthing.""" - _syncthing_load_main_interface(browser) - add_folder_xpath = '//button[contains(@ng-click, "addFolder")]' - browser.find_by_xpath(add_folder_xpath).click() - - folder_dialog = browser.find_by_id('editFolder').first - eventually(lambda: folder_dialog.visible) - browser.find_by_id('folderLabel').fill(folder_name) - browser.find_by_id('folderPath').fill(folder_path) - save_folder_xpath = './/button[contains(@ng-click, "saveFolder")]' - folder_dialog.find_by_xpath(save_folder_xpath).first.click() - eventually(lambda: not folder_dialog.visible) - - -def syncthing_remove_folder(browser, folder_name): - """Remove a folder from Synthing.""" - _syncthing_load_main_interface(browser) - - # Find folder - folder = None - for current_folder in browser.find_by_css('#folders > .panel'): - name = current_folder.find_by_css('.panel-title-text span').first.text - if name == folder_name: - folder = current_folder - break - - # Edit folder button - folder.find_by_css('button.panel-heading').first.click() - eventually(lambda: folder.find_by_css('div.collapse.in')) - edit_folder_xpath = './/button[contains(@ng-click, "editFolder")]' - edit_folder_button = folder.find_by_xpath(edit_folder_xpath).first - edit_folder_button.click() - - # Edit folder dialog - folder_dialog = browser.find_by_id('editFolder').first - eventually(lambda: folder_dialog.visible) - remove_button_xpath = './/button[contains(@data-target, "remove-folder")]' - folder_dialog.find_by_xpath(remove_button_xpath).first.click() - - # Remove confirmation dialog - remove_folder_dialog = browser.find_by_id('remove-folder-confirmation') - eventually(lambda: remove_folder_dialog.visible) - remove_button_xpath = './/button[contains(@ng-click, "deleteFolder")]' - remove_folder_dialog.find_by_xpath(remove_button_xpath).first.click() - - eventually(lambda: not folder_dialog.visible) - - -def _ttrss_load_main_interface(browser): - """Load the TT-RSS interface.""" - access_url(browser, 'tt-rss') - overlay = browser.find_by_id('overlay') - eventually(lambda: not overlay.visible) - - -def _ttrss_is_feed_shown(browser, invert=False): - return browser.is_text_present('Planet Debian') != invert - - -def ttrss_subscribe(browser): - """Subscribe to a feed in TT-RSS.""" - _ttrss_load_main_interface(browser) - browser.find_by_text('Actions...').click() - browser.find_by_text('Subscribe to feed...').click() - browser.find_by_id('feedDlg_feedUrl').fill( - 'https://planet.debian.org/atom.xml') - browser.find_by_text('Subscribe').click() - if browser.is_text_present('You are already subscribed to this feed.'): - browser.find_by_text('Cancel').click() - - expand = browser.find_by_css('span.dijitTreeExpandoClosed') - if expand: - expand.first.click() - - assert eventually(_ttrss_is_feed_shown, [browser]) - - -def ttrss_unsubscribe(browser): - """Unsubscribe from a feed in TT-RSS.""" - _ttrss_load_main_interface(browser) - expand = browser.find_by_css('span.dijitTreeExpandoClosed') - if expand: - expand.first.click() - - browser.find_by_text('Planet Debian').click() - browser.execute_script("quickMenuGo('qmcRemoveFeed')") - prompt = browser.get_alert() - prompt.accept() - - assert eventually(_ttrss_is_feed_shown, [browser, True]) - - -def ttrss_is_subscribed(browser): - """Return whether subscribed to a feed in TT-RSS.""" - _ttrss_load_main_interface(browser) - return browser.is_text_present('Planet Debian') diff --git a/plinth/tests/functional/support/system.py b/plinth/tests/functional/support/system.py deleted file mode 100644 index bd8e3df97..000000000 --- a/plinth/tests/functional/support/system.py +++ /dev/null @@ -1,417 +0,0 @@ -# SPDX-License-Identifier: AGPL-3.0-or-later - -import tempfile -from urllib.parse import urlparse - -import requests - -from . import application, config -from .interface import nav_to_module, submit -from .service import wait_for_page_update - -config_page_title_language_map = { - 'da': 'Generel Konfiguration', - 'de': 'Allgemeine Konfiguration', - 'es': 'Configuración general', - 'fr': 'Configuration générale', - 'nb': 'Generelt oppsett', - 'nl': 'Algemene Instellingen', - 'pl': 'Ustawienia główne', - 'pt': 'Configuração Geral', - 'ru': 'Общие настройки', - 'sv': 'Allmän Konfiguration', - 'te': 'సాధారణ ఆకృతీకరణ', - 'tr': 'Genel Yapılandırma', - 'zh-hans': '常规配置', -} - - -def get_hostname(browser): - nav_to_module(browser, 'config') - return browser.find_by_id('id_hostname').value - - -def set_hostname(browser, hostname): - nav_to_module(browser, 'config') - browser.find_by_id('id_hostname').fill(hostname) - submit(browser) - - -def get_domain_name(browser): - nav_to_module(browser, 'config') - return browser.find_by_id('id_domainname').value - - -def set_domain_name(browser, domain_name): - nav_to_module(browser, 'config') - browser.find_by_id('id_domainname').fill(domain_name) - submit(browser) - - -def set_home_page(browser, home_page): - if 'plinth' not in home_page and 'apache' not in home_page: - home_page = 'shortcut-' + home_page - - nav_to_module(browser, 'config') - drop_down = browser.find_by_id('id_homepage') - drop_down.select(home_page) - submit(browser) - - -def set_advanced_mode(browser, mode): - nav_to_module(browser, 'config') - advanced_mode = browser.find_by_id('id_advanced_mode') - if mode: - advanced_mode.check() - else: - advanced_mode.uncheck() - - submit(browser) - - -def set_language(browser, language_code): - username = config['DEFAULT']['username'] - browser.visit(config['DEFAULT']['url'] + - '/plinth/sys/users/{}/edit/'.format(username)) - browser.find_by_xpath('//select[@id="id_language"]//option[@value="' + - language_code + '"]').first.click() - submit(browser) - - -def check_language(browser, language_code): - nav_to_module(browser, 'config') - return browser.find_by_css('.app-titles').first.find_by_tag( - 'h2').first.value == config_page_title_language_map[language_code] - - -def delete_all_snapshots(browser): - if get_snapshot_count(browser): - browser.find_by_id('select-all').check() - submit(browser, browser.find_by_name('delete_selected')) - - confirm_button = browser.find_by_name('delete_confirm') - if confirm_button: # Only if redirected to confirm page - submit(browser, confirm_button) - - -def create_snapshot(browser): - browser.visit(config['DEFAULT']['url'] + '/plinth/sys/snapshot/manage/') - submit(browser) # Click on 'Create Snapshot' - - -def get_snapshot_count(browser): - browser.visit(config['DEFAULT']['url'] + '/plinth/sys/snapshot/manage/') - # Subtract 1 for table header - return len(browser.find_by_xpath('//tr')) - 1 - - -def snapshot_set_configuration(browser, free_space, timeline_enabled, - software_enabled, hourly, daily, weekly, - monthly, yearly): - """Set the configuration for snapshots.""" - nav_to_module(browser, 'snapshot') - browser.find_by_name('free_space').select(free_space / 100) - browser.find_by_name('enable_timeline_snapshots').select( - 'yes' if timeline_enabled else 'no') - browser.find_by_name('enable_software_snapshots').select( - 'yes' if software_enabled else 'no') - browser.find_by_name('hourly_limit').fill(hourly) - browser.find_by_name('daily_limit').fill(daily) - browser.find_by_name('weekly_limit').fill(weekly) - browser.find_by_name('monthly_limit').fill(monthly) - browser.find_by_name('yearly_limit').fill(yearly) - submit(browser) - - -def snapshot_get_configuration(browser): - """Return the current configuration for snapshots.""" - nav_to_module(browser, 'snapshot') - return (int(float(browser.find_by_name('free_space').value) * 100), - browser.find_by_name('enable_timeline_snapshots').value == 'yes', - browser.find_by_name('enable_software_snapshots').value == 'yes', - int(browser.find_by_name('hourly_limit').value), - int(browser.find_by_name('daily_limit').value), - int(browser.find_by_name('weekly_limit').value), - int(browser.find_by_name('monthly_limit').value), - int(browser.find_by_name('yearly_limit').value)) - - -def check_home_page_redirect(browser, app_name): - browser.visit(config['DEFAULT']['url']) - return browser.find_by_xpath( - "//a[contains(@href, '/plinth/') and @title='FreedomBox']") - - -def dynamicdns_configure(browser): - nav_to_module(browser, 'dynamicdns') - browser.find_link_by_href( - '/plinth/sys/dynamicdns/configure/').first.click() - browser.find_by_id('id_enabled').check() - browser.find_by_id('id_service_type').select('GnuDIP') - browser.find_by_id('id_dynamicdns_server').fill('example.com') - browser.find_by_id('id_dynamicdns_domain').fill('freedombox.example.com') - browser.find_by_id('id_dynamicdns_user').fill('tester') - browser.find_by_id('id_dynamicdns_secret').fill('testingtesting') - browser.find_by_id('id_dynamicdns_ipurl').fill( - 'http://myip.datasystems24.de') - submit(browser) - - -def dynamicdns_has_original_config(browser): - nav_to_module(browser, 'dynamicdns') - browser.find_link_by_href( - '/plinth/sys/dynamicdns/configure/').first.click() - enabled = browser.find_by_id('id_enabled').value - service_type = browser.find_by_id('id_service_type').value - server = browser.find_by_id('id_dynamicdns_server').value - domain = browser.find_by_id('id_dynamicdns_domain').value - user = browser.find_by_id('id_dynamicdns_user').value - ipurl = browser.find_by_id('id_dynamicdns_ipurl').value - if enabled and service_type == 'GnuDIP' and server == 'example.com' \ - and domain == 'freedombox.example.com' and user == 'tester' \ - and ipurl == 'http://myip.datasystems24.de': - return True - else: - return False - - -def dynamicdns_change_config(browser): - nav_to_module(browser, 'dynamicdns') - browser.find_link_by_href( - '/plinth/sys/dynamicdns/configure/').first.click() - browser.find_by_id('id_enabled').check() - browser.find_by_id('id_service_type').select('GnuDIP') - browser.find_by_id('id_dynamicdns_server').fill('2.example.com') - browser.find_by_id('id_dynamicdns_domain').fill('freedombox2.example.com') - browser.find_by_id('id_dynamicdns_user').fill('tester2') - browser.find_by_id('id_dynamicdns_secret').fill('testingtesting2') - browser.find_by_id('id_dynamicdns_ipurl').fill( - 'http://myip2.datasystems24.de') - submit(browser) - - -def _click_button_and_confirm(browser, href): - buttons = browser.find_link_by_href(href) - if buttons: - buttons.first.click() - with wait_for_page_update(browser, - expected_url='/plinth/sys/backups/'): - submit(browser) - - -def backup_delete_archive_by_name(browser, archive_name): - nav_to_module(browser, 'backups') - href = f'/plinth/sys/backups/root/delete/{archive_name}/' - _click_button_and_confirm(browser, href) - - -def backup_create(browser, app_name, archive_name=None): - application.install(browser, 'backups') - if archive_name: - backup_delete_archive_by_name(browser, archive_name) - - browser.find_link_by_href('/plinth/sys/backups/create/').first.click() - browser.find_by_id('select-all').uncheck() - if archive_name: - browser.find_by_id('id_backups-name').fill(archive_name) - - # ensure the checkbox is scrolled into view - browser.execute_script('window.scrollTo(0, 0)') - browser.find_by_value(app_name).first.check() - submit(browser) - - -def backup_restore(browser, app_name, archive_name=None): - nav_to_module(browser, 'backups') - href = f'/plinth/sys/backups/root/restore-archive/{archive_name}/' - _click_button_and_confirm(browser, href) - - -def backup_upload_and_restore(browser, app_name, downloaded_file_path): - nav_to_module(browser, 'backups') - browser.find_link_by_href('/plinth/sys/backups/upload/').first.click() - fileinput = browser.driver.find_element_by_id('id_backups-file') - fileinput.send_keys(downloaded_file_path) - # submit upload form - submit(browser) - # submit restore form - with wait_for_page_update(browser, expected_url='/plinth/sys/backups/'): - submit(browser) - - -def download_backup(browser, archive_name=None): - nav_to_module(browser, 'backups') - href = f'/plinth/sys/backups/root/download/{archive_name}/' - url = config['DEFAULT']['url'] + href - file_path = download_file_logged_in(browser, url, suffix='.tar.gz') - return file_path - - -def download_file_logged_in(browser, url, suffix=''): - """Download a file from Plinth, pretend being logged in via cookies""" - if not url.startswith("http"): - current_url = urlparse(browser.url) - url = "%s://%s%s" % (current_url.scheme, current_url.netloc, url) - cookies = browser.driver.get_cookies() - cookies = {cookie["name"]: cookie["value"] for cookie in cookies} - response = requests.get(url, verify=False, cookies=cookies) - with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as temp_file: - for chunk in response.iter_content(chunk_size=128): - temp_file.write(chunk) - return temp_file.name - - -def pagekite_configure(browser, host, port, kite_name, kite_secret): - """Configure pagekite basic parameters.""" - nav_to_module(browser, 'pagekite') - # time.sleep(0.250) # Wait for 200ms show animation to complete - browser.fill('pagekite-server_domain', host) - browser.fill('pagekite-server_port', str(port)) - browser.fill('pagekite-kite_name', kite_name) - browser.fill('pagekite-kite_secret', kite_secret) - submit(browser, form_class='form-configuration') - - -def pagekite_get_configuration(browser): - """Return pagekite basic parameters.""" - nav_to_module(browser, 'pagekite') - return (browser.find_by_name('pagekite-server_domain').value, - int(browser.find_by_name('pagekite-server_port').value), - browser.find_by_name('pagekite-kite_name').value, - browser.find_by_name('pagekite-kite_secret').value) - - -def bind_set_forwarders(browser, forwarders): - """Set the forwarders list (space separated) in bind configuration.""" - nav_to_module(browser, 'bind') - browser.fill('forwarders', forwarders) - submit(browser, form_class='form-configuration') - - -def bind_get_forwarders(browser): - """Return the forwarders list (space separated) in bind configuration.""" - nav_to_module(browser, 'bind') - return browser.find_by_name('forwarders').first.value - - -def bind_enable_dnssec(browser, enable): - """Enable/disable DNSSEC in bind configuration.""" - nav_to_module(browser, 'bind') - if enable: - browser.check('enable_dnssec') - else: - browser.uncheck('enable_dnssec') - - submit(browser, form_class='form-configuration') - - -def bind_get_dnssec(browser): - """Return whether DNSSEC is enabled/disabled in bind configuration.""" - nav_to_module(browser, 'bind') - return browser.find_by_name('enable_dnssec').first.checked - - -def security_enable_restricted_logins(browser, should_enable): - """Enable/disable restricted logins in security module.""" - nav_to_module(browser, 'security') - if should_enable: - browser.check('security-restricted_access') - else: - browser.uncheck('security-restricted_access') - - submit(browser) - - -def security_get_restricted_logins(browser): - """Return whether restricted console logins is enabled.""" - nav_to_module(browser, 'security') - return browser.find_by_name('security-restricted_access').first.checked - - -def upgrades_enable_automatic(browser, should_enable): - """Enable/disable automatic software upgrades.""" - nav_to_module(browser, 'upgrades') - checkbox_element = browser.find_by_name('auto_upgrades_enabled').first - if should_enable == checkbox_element.checked: - return - - if should_enable: - checkbox_element.check() - else: - checkbox_element.uncheck() - - submit(browser) - - -def upgrades_get_automatic(browser): - """Return whether automatic software upgrades is enabled.""" - nav_to_module(browser, 'upgrades') - return browser.find_by_name('auto_upgrades_enabled').first.checked - - -def _monkeysphere_find_domain(browser, key_type, domain_type, domain): - """Iterate every domain of a given type which given key type.""" - keys_of_type = browser.find_by_css( - '.monkeysphere-service-{}'.format(key_type)) - for key_of_type in keys_of_type: - search_domains = key_of_type.find_by_css( - '.monkeysphere-{}-domain'.format(domain_type)) - for search_domain in search_domains: - if search_domain.text == domain: - return key_of_type, search_domain - - raise IndexError('Domain not found') - - -def monkeysphere_import_key(browser, key_type, domain): - """Import a key of specified type for given domain into monkeysphere.""" - try: - monkeysphere_assert_imported_key(browser, key_type, domain) - except IndexError: - pass - else: - return - - key, _ = _monkeysphere_find_domain(browser, key_type, 'importable', domain) - with wait_for_page_update(browser): - key.find_by_css('.button-import').click() - - -def monkeysphere_assert_imported_key(browser, key_type, domain): - """Assert that a key of specified type for given domain was imported..""" - nav_to_module(browser, 'monkeysphere') - return _monkeysphere_find_domain(browser, key_type, 'imported', domain) - - -def monkeysphere_publish_key(browser, key_type, domain): - """Publish a key of specified type for given domain from monkeysphere.""" - nav_to_module(browser, 'monkeysphere') - key, _ = _monkeysphere_find_domain(browser, key_type, 'imported', domain) - with wait_for_page_update(browser): - key.find_by_css('.button-publish').click() - - application.wait_for_config_update(browser, 'monkeysphere') - - -def open_main_page(browser): - with wait_for_page_update(browser): - browser.find_link_by_href('/plinth/').first.click() - - -def networks_set_firewall_zone(browser, zone): - """"Set the network device firewall zone as internal or external.""" - nav_to_module(browser, 'networks') - device = browser.find_by_xpath( - '//span[contains(@class, "label-success") ' - 'and contains(@class, "connection-status-label")]/following::a').first - network_id = device['href'].split('/')[-3] - device.click() - edit_url = "/plinth/sys/networks/{}/edit/".format(network_id) - browser.find_link_by_href(edit_url).first.click() - browser.select('zone', zone) - browser.find_by_tag("form").first.find_by_tag('input')[-1].click() - - -def storage_is_root_disk_shown(browser): - table_cells = browser.find_by_tag('td') - return any(cell.text == '/' for cell in table_cells)