Sunil Mohan Adapa 80d67c2054
tests: functional: Merge into main source hierarchy
- Add pytest hooks to ignore all functional tests if pytest_bdd is not
installed.

- Update pytest hooks to skip tests in file named 'test_functional.py' if
--include-functional argument is not provided.

- Move functional_tests/install.py into plinth/tests/functional and update
reference in Vagrantfile.

- Move scenario files into individual app folders. Rename them after the app
they are testing. Merge TODO items listed in todo.org into corresponding feature
files.

- Add test_functional.py in each app to build tests from the features file using
pytest_bdd.

- Move all step_definitions, support and data into plinth/tests/functional/.
Include all step_definitions from conftest.py. Update to relative imports
instead of absolute imports.

Tests performed:

- Run py.test-3 --collect-only shows all functional tests and lists 574 tests.
No errors show that name of feature files are correct. The number says that all
functional test features are included.

- Remove pytest_bdd (or modify the import name) and run py.test-3 --collect-only
skips collecting all functional tests and shows only 300+ tests.

- Run functional tests for a few apps with py.test-3 --include-functional -m
app. For storage, deluge.

- Run unit tests with py.test-3. Functional tests are listed by skipped.

Signed-off-by: Sunil Mohan Adapa <sunil@medhas.org>
Reviewed-by: Joseph Nuthalapati <njoseph@riseup.net>
2020-05-22 21:26:05 +05:30

753 lines
26 KiB
Python

# 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