# 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', '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)