From aef0dcd381aa64d45c9d7f8347c58e2181938577 Mon Sep 17 00:00:00 2001 From: Fioddor Superconcentrado Date: Mon, 18 Jan 2021 00:53:32 +0100 Subject: [PATCH 1/2] test: help: Add help view tests Signed-off-by: Fioddor Superconcentrado [sunil: Yapf and isort, flak8 warnings, spelling] [sunil: Drop debugging code] [sunil: Use pytest parametrize, skip marks] [sunil: Minor test improvements] Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa --- plinth/modules/help/tests/test_views.py | 275 ++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 plinth/modules/help/tests/test_views.py diff --git a/plinth/modules/help/tests/test_views.py b/plinth/modules/help/tests/test_views.py new file mode 100644 index 000000000..3ab4c53ab --- /dev/null +++ b/plinth/modules/help/tests/test_views.py @@ -0,0 +1,275 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Tests for help views. + +Design: - Make tests independent from URL policy by using Django names instead + of URLs to call the help module. For this, some additional fixture + work is needed: pytestmark and fixture_app_urls(). + +Pending: - status log + +""" + +import pathlib +import subprocess +from unittest.mock import patch + +import pytest +from django import urls +from django.conf import settings +from django.http import Http404 + +from plinth import module_loader +from plinth.modules.help import views + +# For all tests, use plinth.urls instead of urls configured for testing +pytestmark = pytest.mark.urls('plinth.urls') + + +def _is_page(response): + """Minimal check on help views.""" + return (response.status_code == 200 and 'title' in response.context_data + and response['content-type'] == 'text/html; charset=utf-8') + + +@pytest.fixture(autouse=True, scope='module') +def fixture_app_urls(): + """Make sure app's URLs are part of plinth.urls.""" + with patch('plinth.module_loader._modules_to_load', new=[]) as modules, \ + patch('plinth.urls.urlpatterns', new=[]): + modules.append('plinth.modules.help') + module_loader.include_urls() + yield + + +@pytest.mark.parametrize("view_name, view", ( + ('contribute', views.contribute), + ('feedback', views.feedback), + ('support', views.support), + ('index', views.index), +)) +def test_simple_help_pages(rf, view_name, view): + """Simple common test for certain help views.""" + response = view(rf.get(urls.reverse('help:' + view_name))) + assert _is_page(response) + + +def test_about(rf): + """Test some expected items in about view.""" + manual_url = urls.reverse('help:manual') + response = views.about(rf.get(manual_url)) + assert _is_page(response) + for item in ('version', 'new_version', 'os_release'): + assert item in response.context_data + + +# --------------------------------------------------------------------------- +# Tests for serving the offline user guide ( the "manual") +# +# The manual can be requested: +# - Either complete on a single page or page by page. +# - Specifying (or not) the language. +# - The complete manual can be requested in HTML or PDF formats. +# +# Expected Behaviour Rules: +# - If the page isn't specified, the help module returns the full manual in +# one single page. +# - The help module tries first to return the page in the specified +# language. If not found (either that page doesn't exist or the language +# wasn't secified) it falls back to its twin in the fallback language. If +# it is neither available, it shows a proper error message. +# +# Design Decisions: +# - The PDF manual has a separate function to serve it. +# - The 'Manual' page doesn't exist as such. However there are files named +# 'Manual' containing the complete full manual in one single page. +# - In order to avoid loops, the fallback language is intercepted and +# treated specifically. +# - Problem: Requesting a missing page in a language that happens to be +# the fallback one, missing it would cause the help module to +# redirect to the same page, closing thereby a neverending loop. +# The web served would probably break that loop, but it would +# cause confusion to the user. +# - CI environments don't setup FreedomBox completely. A regular setup run is +# impractically slow (10-15 mins), if even posible. Compiling and deploying +# the manual is just 3 extra lines in .gitlab-ci.yml file: +# - make -C doc +# - mkdir -p /usr/share/freedombox/manual +# - cp -r doc/manual /usr/share/freedombox/manual +# But again, this causes the 4 minutes of test preparation to bump to 6. +# It's not worth for just testing the offline manual, so the tests guess if +# they are running in a restricted environment and skip. + +canary = pathlib.Path('doc/manual/en/Coturn.part.html') +TRANSLATIONS = ('es', ) +MANUAL_PAGES = ('Apache_userdir', 'APU', 'Backups', 'BananaPro', 'BeagleBone', + 'bepasty', 'Bind', 'Calibre', 'Cockpit', 'Configure', + 'Contribute', 'Coturn', 'Cubieboard2', 'Cubietruck', + 'DateTime', 'Debian', 'Deluge', 'Developer', 'Diagnostics', + 'Download', 'DynamicDNS', 'ejabberd', 'Firewall', + 'freedombox-manual', 'GettingHelp', 'GitWeb', 'Hardware', + 'I2P', 'Ikiwiki', 'Infinoted', 'Introduction', 'JSXC', + 'LetsEncrypt', 'Maker', 'MatrixSynapse', 'MediaWiki', + 'Minetest', 'MiniDLNA', 'MLDonkey', 'Monkeysphere', 'Mumble', + 'NameServices', 'Networks', 'OpenVPN', 'OrangePiZero', + 'PageKite', 'pcDuino3', 'Performance', 'PineA64+', + 'PioneerEdition', 'Plinth', 'Power', 'Privoxy', 'Quassel', + 'QuickStart', 'Radicale', 'RaspberryPi2', 'RaspberryPi3B+', + 'RaspberryPi3B', 'RaspberryPi4B', 'ReleaseNotes', 'Rock64', + 'RockPro64', 'Roundcube', 'Samba', 'Searx', 'SecureShell', + 'Security', 'ServiceDiscovery', 'Shadowsocks', 'Sharing', + 'Snapshots', 'Storage', 'Syncthing', 'TinyTinyRSS', 'Tor', + 'Transmission', 'Upgrades', 'USBWiFi', 'Users', 'VirtualBox', + 'WireGuard') +_restricted_reason = ('Needs installed manual. ' + 'CI speed-optimized workspace does not provide it.') +not_restricted_environment = pytest.mark.skipif(not canary.exists(), + reason=_restricted_reason) + + +@pytest.mark.parametrize('lang', (None, '-')) +def test_full_default_manual(rf, lang): + """Test request for the full default manual. + + Expected: Redirect to the full manual in the fallback language. + + """ + manual_url = urls.reverse('help:manual') + response = views.manual(rf.get(manual_url), lang=lang) + assert response.status_code == 302 + assert response.url == '/help/manual/en/' + + # With a language cookie set + request = rf.get(manual_url) + request.COOKIES[settings.LANGUAGE_COOKIE_NAME] = TRANSLATIONS[0] + response = views.manual(request, lang=lang) + assert response.status_code == 302 + assert response.url == f'/help/manual/{TRANSLATIONS[0]}/' + + +@pytest.mark.parametrize('lang', (None, '-')) +def test_default_manual_by_pages(rf, lang): + """Test page-specific requests for the (default) manual. + + Expected: Redirect to their respective twins in the fallback language. + Pending.: Redirect pages with plus-sign '+' in their name. + + """ + manual_url = urls.reverse('help:manual') + for page in MANUAL_PAGES: + if '+' in page or 'Manual' in page: # Pine64+ & RaspberryPi3B+ + continue + + response = views.manual(rf.get(manual_url), lang=lang, page=page) + assert response.status_code == 302 + assert response.url == '/help/manual/en/' + page + + # With a language cookie set + request = rf.get(manual_url) + request.COOKIES[settings.LANGUAGE_COOKIE_NAME] = TRANSLATIONS[0] + response = views.manual(request, lang=lang, page=page) + assert response.status_code == 302 + assert response.url == f'/help/manual/{TRANSLATIONS[0]}/{page}' + + +@not_restricted_environment +def test_specific_full_manual_translation(rf): + """Test request for specific translated manuals. + + Expected: All return a page. + + """ + manual_url = urls.reverse('help:manual') + for lang in ('es', 'en'): + response = views.manual(rf.get(manual_url), lang=lang) + assert _is_page(response) + + +@not_restricted_environment +def test_specific_manual_translation_by_pages(rf): + """Test that translated-page-specific requests. + + Expected: All known page names return pages. + + """ + manual_url = urls.reverse('help:manual') + for lang in ('es', 'en'): + for page in MANUAL_PAGES: + response = views.manual(rf.get(manual_url), page=page, lang=lang) + assert _is_page(response) + + +@not_restricted_environment +def test_full_manual_requested_by_page_name(rf): + """Test requests for 'Manual'. + + Note: 'Manual' is a file, not a manual page. + Expected: Return a proper not found message (HTTP 404) + Currently: Non fallback languages return a page. + This is wrong, but doesn't cause any harm. + + """ + manual_url = urls.reverse('help:manual') + page = 'Manual' + + for lang in TRANSLATIONS: + response = views.manual(rf.get(manual_url), page=page, lang=lang) + assert _is_page(response) + + with pytest.raises(Http404): + views.manual(rf.get(manual_url), page=page, lang='en') + + +def test_missing_page(rf): + """Test requests for missing pages. + + Expected: + - Unspecified language: Fall back to its fallback twin. + - Translated languages: Fall back to its fallback twin. + - Fallback language...: Return a proper not found message (HTTP 404) + - Unknown languages...: Fall back to its fallback twin. + + """ + manual_url = urls.reverse('help:manual') + page = 'unknown' + for lang in TRANSLATIONS + ('unknown', None): + response = views.manual(rf.get(manual_url), page=page, lang=lang) + assert response.status_code == 302 + assert response.url == '/help/manual/en/unknown' + + with pytest.raises(Http404): + views.manual(rf.get(manual_url), page=page, lang='en') + + +@not_restricted_environment +def test_download_full_manual_file(rf, tmp_path): + """Test download of manual. + + Design: - Downloads the default manual, a translated one and the + fallback translation. None should fail. Then compares + them. + - Call diff command for fast comparision. Comparing the + over 10MB bytestrings in python is insanely slow. + + """ + + def _diff(file_name_a, file_name_b, same): + file_a = tmp_path / file_name_a + file_b = tmp_path / file_name_b + process = subprocess.run( + ['diff', '-q', str(file_a), str(file_b)], check=False) + assert bool(process.returncode) != same + + url = urls.reverse('help:manual') + manuals = { + 'unspecified': rf.get(url), + 'translated': rf.get(url, HTTP_ACCEPT_LANGUAGE='es'), + 'fallback': rf.get(url, HTTP_ACCEPT_LANGUAGE='en') + } + for name, request in manuals.items(): + response = views.download_manual(request) + assert response.status_code == 200 + file = tmp_path / (name + '.pdf') + file.write_bytes(response.content) + + _diff('fallback.pdf', 'unspecified.pdf', same=True) + _diff('fallback.pdf', 'translated.pdf', same=False) From e11ce5f58f42842088dda2ba4058c89a77fbfc24 Mon Sep 17 00:00:00 2001 From: Fioddor Superconcentrado Date: Mon, 8 Feb 2021 18:43:05 +0100 Subject: [PATCH 2/2] test: Add tests for action utilities Signed-off-by: Fioddor Superconcentrado [sunil: Minor refactoring, relax a test to make it work on CI] [sunil: Run tests only when systemd, ip commands are available] Signed-off-by: Sunil Mohan Adapa Reviewed-by: Sunil Mohan Adapa --- plinth/tests/test_action_utils.py | 139 ++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 plinth/tests/test_action_utils.py diff --git a/plinth/tests/test_action_utils.py b/plinth/tests/test_action_utils.py new file mode 100644 index 000000000..b347cedae --- /dev/null +++ b/plinth/tests/test_action_utils.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +""" +Test module for key/value store. +""" + +import json +import pathlib +import subprocess +from unittest.mock import patch + +import pytest + +from plinth.action_utils import (get_addresses, get_hostname, + is_systemd_running, service_action, + service_disable, service_enable, + service_is_enabled, service_is_running, + service_reload, service_restart, + service_start, service_stop, + service_try_restart, service_unmask) + +UNKNOWN = 'unknowndeamon' + +systemctl_path = pathlib.Path('/usr/bin/systemctl') +systemd_installed = pytest.mark.skipif(not systemctl_path.exists(), + reason='systemd not available') + +ip_path = pathlib.Path('/usr/bin/ip') +ip_installed = pytest.mark.skipif(not ip_path.exists(), + reason='ip command not available') + + +@patch('os.path.exists') +def test_is_systemd_running(mock): + """Trivial white box test for a trivial implementation.""" + mock.return_value = True + assert is_systemd_running() + mock.return_value = False + assert not is_systemd_running() + + +@systemd_installed +def test_service_checks(): + """Test basic checks on status of an arbitrary service.""" + assert not service_is_running(UNKNOWN) + assert not service_is_enabled(UNKNOWN) + + # expected is best if: generic. Alternatives: systemd-sysctl, logrotate + expected = 'networking' + if not service_is_running(expected): + pytest.skip(f'Needs service {expected} running.') + + assert service_is_enabled(expected) + + +@pytest.mark.usefixtures('needs_root') +@systemd_installed +def test_service_enable_and_disable(): + """Test enabling and disabling of an arbitrary service.""" + # service is best if: non-essential part of FreedomBox that restarts fast + service = 'unattended-upgrades' + if not service_is_enabled(service): + reason = f'Needs service {service} enabled.' + pytest.skip(reason) + + service_disable(service) + assert not service_is_running(service) + service_enable(service) + assert service_is_running(service) + + # Ignore unknown services, don't fail: + service_disable(UNKNOWN) + service_enable(UNKNOWN) + + +@patch('plinth.action_utils.service_action') +@systemd_installed +def test_service_actions(mock): + """Trivial white box test for trivial implementations.""" + service_start(UNKNOWN) + mock.assert_called_with(UNKNOWN, 'start') + service_stop(UNKNOWN) + mock.assert_called_with(UNKNOWN, 'stop') + service_restart(UNKNOWN) + mock.assert_called_with(UNKNOWN, 'restart') + service_try_restart(UNKNOWN) + mock.assert_called_with(UNKNOWN, 'try-restart') + service_reload(UNKNOWN) + mock.assert_called_with(UNKNOWN, 'reload') + + +@pytest.mark.usefixtures('needs_root') +@systemd_installed +def test_service_unmask(): + """Test unmasking of an arbitrary masked service.""" + + def is_masked(service): + process = subprocess.run([ + 'systemctl', 'list-unit-files', '--output=json', + service + '.service' + ], stdout=subprocess.PIPE, check=False) + output = json.loads(process.stdout) + return output[0]['state'] == 'masked' if output else False + + # SERVICE is best if: part of FreedomBox, so we can mess with least risk. + service = 'samba-ad-dc' + if not is_masked(service): + pytest.skip(f'Needs service {service} masked.') + + service_unmask(service) + assert not is_masked(service) + + service_action(service, 'mask') + assert is_masked(service) + + +def test_get_hostname(): + """get_hostname returns a string. + + In fact, the maximum length for a hostname is 253 characters, but + anything longer than 80 is very suspicious, so we fail the test. + + To avoid error messages pass as hostnames we seek and fail if we find + some unexpected characters. + """ + hostname = get_hostname() + assert hostname + assert isinstance(hostname, str) + assert len(hostname) < 80 + for char in ' ,:;!?=$%&@*+()[]{}<>"\'': + assert char not in hostname + + +@ip_installed +def test_get_addresses(): + """Test that any FreedomBox has some addresses.""" + ips = get_addresses() + assert len(ips) > 3 # min: ip, 2x'localhost', hostname + for address in ips: + assert address['kind'] in ('4', '6')