# SPDX-License-Identifier: AGPL-3.0-or-later """ Test module for package module. """ import time import unittest from unittest.mock import Mock, call, patch import pytest from plinth.app import App from plinth.diagnostic_check import DiagnosticCheck, Result from plinth.errors import MissingPackageError from plinth.package import Package, Packages, packages_installed @pytest.fixture(autouse=True) def fixture_clean_apps(): """Fixture to ensure clean set of global apps.""" App._all_apps = {} class TestPackageExpressions(unittest.TestCase): def test_package(self): """Test resolving package names.""" package = Package('python3') assert package.possible() == ['python3'] assert package.actual() == 'python3' package = Package('unknown-package') assert package.possible() == ['unknown-package'] self.assertRaises(MissingPackageError, package.actual) def test_package_or_expression(self): """Test resolving package OR expressions.""" expression = Package('python3') | Package('unknown-package') assert expression.possible() == ['python3', 'unknown-package'] assert expression.actual() == 'python3' expression = Package('unknown-package') | Package('python3') assert expression.possible() == ['unknown-package', 'python3'] assert expression.actual() == 'python3' # When both packages are available, prefer the first. expression = Package('bash') | Package('dash') assert expression.possible() == ['bash', 'dash'] assert expression.actual() == 'bash' expression = Package('unknown-package') | Package( 'another-unknown-package') assert expression.possible() == [ 'unknown-package', 'another-unknown-package' ] self.assertRaises(MissingPackageError, expression.actual) def test_packages_init(): """Test initialization of packages component.""" component = Packages('test-component', ['foo', 'bar']) assert component.possible_packages == ['foo', 'bar'] assert component.component_id == 'test-component' assert not component.skip_recommends assert component.conflicts == [] assert component.conflicts_action is None assert not component.rerun_setup_on_upgrade with pytest.raises(ValueError): Packages(None, []) component = Packages('test-component', [], skip_recommends=True, conflicts=['conflict1', 'conflict2'], conflicts_action=Packages.ConflictsAction.IGNORE, rerun_setup_on_upgrade=True) assert component.possible_packages == [] assert component.skip_recommends assert component.conflicts == ['conflict1', 'conflict2'] assert component.conflicts_action == Packages.ConflictsAction.IGNORE assert component.rerun_setup_on_upgrade @patch('apt.Cache') def test_packages_get_actual_packages(cache): """Test resolving of package expressions to actual packages.""" cache.return_value = { 'python3': Mock(name='python3', candidate='1.0'), 'janus': Mock(name='janus', candidate=None) } component = Packages('test-component', ['python3']) assert component.get_actual_packages() == ['python3'] component = Packages('test-component', ['unknown-package']) with pytest.raises(MissingPackageError): component.get_actual_packages() component = Packages('test-component', [Package('unknown-package') | Package('python3')]) assert component.get_actual_packages() == ['python3'] component = Packages('test-component', [], skip_recommends=True, conflicts=['conflict1', 'conflict2'], conflicts_action=Packages.ConflictsAction.IGNORE) assert component.get_actual_packages() == [] component = Packages('test-component', ['janus']) with pytest.raises(MissingPackageError): component.get_actual_packages() @patch('plinth.package.install') def test_packages_setup(install): """Test setting up packages component.""" class TestApp(App): """Test app""" app_id = 'test-app' component = Packages('test-component', ['python3', 'bash']) app = TestApp() app.add(component) app.setup(old_version=3) install.assert_has_calls( [call(['python3', 'bash'], skip_recommends=False)]) component = Packages('test-component', ['bash', 'perl'], skip_recommends=True) app = TestApp() app.add(component) app.setup(old_version=3) install.assert_has_calls([call(['bash', 'perl'], skip_recommends=True)]) component = Packages('test-component', [Package('python3') | Package('unknown-package')]) app = TestApp() app.add(component) app.setup(old_version=3) install.assert_has_calls([call(['python3'], skip_recommends=False)]) @patch('plinth.package.packages_installed') @patch('plinth.package.uninstall') @patch('plinth.package.install') def test_packages_setup_with_conflicts(install, uninstall, packages_installed): """Test setting up packages with conflicts.""" packages_installed.return_value = ['exim4-base'] component = Packages('test-component', ['bash'], conflicts=['exim4-base'], conflicts_action=Packages.ConflictsAction.REMOVE) component.setup(old_version=0) uninstall.assert_has_calls([call(['exim4-base'], purge=False)]) install.assert_has_calls([call(['bash'], skip_recommends=False)]) uninstall.reset_mock() install.reset_mock() component = Packages('test-component', ['bash'], conflicts=['exim4-base']) component.setup(old_version=0) uninstall.assert_not_called() install.assert_has_calls([call(['bash'], skip_recommends=False)]) uninstall.reset_mock() install.reset_mock() component = Packages('test-component', ['bash'], conflicts=['exim4-base', 'not-installed-package'], conflicts_action=Packages.ConflictsAction.IGNORE) component.setup(old_version=0) uninstall.assert_not_called() install.assert_has_calls([call(['bash'], skip_recommends=False)]) @patch('plinth.package.refresh_package_lists') @patch('plinth.package.uninstall') def test_packages_uninstall(uninstall, _refresh_package_lists): """Test uninstalling packages component.""" class TestApp(App): """Test app""" app_id = 'test-app' component = Packages('test-component', ['bash', 'dash']) app = TestApp() app.add(component) app.uninstall() uninstall.assert_has_calls([call(['bash', 'dash'], purge=True)]) @patch('plinth.package.refresh_package_lists') @patch('plinth.package.uninstall') @patch('apt.Cache') def test_packages_uninstall_exclusion(cache, uninstall, _refresh_package_lists): """Test excluding packages from other installed apps when uninstalling.""" def _get_mock_package(installed_version='1.0', dependencies=None, recommends=None): mock_dependencies = [] for or_dependencies in (dependencies or []): mock_or_dependency = Mock(or_dependencies=[]) mock_dependencies.append(mock_or_dependency) for dependency in or_dependencies: mock = Mock() mock.name = dependency mock_or_dependency.or_dependencies.append(mock) mock_recommends = [] for or_dependencies in (recommends or []): mock_or_dependency = Mock(or_dependencies=[]) mock_recommends.append(mock_or_dependency) for dependency in or_dependencies: mock = Mock() mock.name = dependency mock_or_dependency.or_dependencies.append(mock) mock = Mock( version=installed_version or '1.0', is_installed=bool(installed_version), installed=Mock(dependencies=mock_dependencies, recommends=mock_recommends)) return mock package2 = _get_mock_package('4.0', [['dep1', 'dep2'], ['dep3'], ['dep4']], [['dep5']]) cache.return_value = { 'package11': _get_mock_package('2.0'), 'package12': _get_mock_package(None), 'package2': package2, 'package3': _get_mock_package('5.0', ['unknown-dep1']), 'dep1': _get_mock_package('6.0'), 'dep2': _get_mock_package('6.1'), 'dep3': _get_mock_package('6.2'), 'dep4': _get_mock_package(None), 'dep5': _get_mock_package('6.4'), 'dep6': _get_mock_package('6.5'), } class TestApp1(App): """Test app.""" app_id = 'test-app1' def __init__(self): super().__init__() component = Packages('test-component11', [ 'package11', 'package2', 'package3', 'dep1', 'dep2', 'dep3', 'dep4', 'dep6' ]) self.add(component) component = Packages('test-component12', ['package12', 'package2', 'package3']) self.add(component) class TestApp2(App): """Test app.""" app_id = 'test-app2' def __init__(self): super().__init__() component = Packages('test-component2', ['package2']) self.add(component) def get_setup_state(self): return App.SetupState.UP_TO_DATE class TestApp3(App): """Test app.""" app_id = 'test-app3' def __init__(self): super().__init__() component = Packages('test-component3', ['package3']) self.add(component) def get_setup_state(self): return App.SetupState.NEEDS_SETUP app1 = TestApp1() TestApp2() TestApp3() app1.uninstall() uninstall.assert_has_calls([ call(['package12', 'package3'], purge=True), call(['package11', 'package3', 'dep6'], purge=True) ]) @patch('apt.Cache') def test_diagnose(cache): """Test checking for latest version of the package.""" cache.return_value = { 'package2': Mock(candidate=Mock(version='2.0', is_installed=True)), 'package3': Mock(candidate=Mock(version='3.0', is_installed=False)), 'package7': Mock(candidate=Mock(version='4.0', is_installed=True)), } component = Packages('test-component', [ 'package1', 'package2', 'package3', Package('package4') | Package('package5'), Package('package6') | Package('package7') ]) results = component.diagnose() assert results == [ DiagnosticCheck( 'package-available-package1', 'Package {package_expression} is not available for install', Result.FAILED, {'package_expression': 'package1'}, 'test-component'), DiagnosticCheck( 'package-latest-package2', 'Package {package_name} is the latest version ({latest_version})', Result.PASSED, { 'package_name': 'package2', 'latest_version': '2.0' }, 'test-component'), DiagnosticCheck( 'package-latest-package3', 'Package {package_name} is the latest version ({latest_version})', Result.WARNING, { 'package_name': 'package3', 'latest_version': '3.0' }, 'test-component'), DiagnosticCheck( 'package-available-package4 | package5', 'Package {package_expression} is not available for install', Result.FAILED, {'package_expression': 'package4 | package5'}, 'test-component'), DiagnosticCheck( 'package-latest-package7', 'Package {package_name} is the latest version ({latest_version})', Result.PASSED, { 'package_name': 'package7', 'latest_version': '4.0' }, 'test-component'), ] @patch('plinth.package.packages_installed') def test_packages_find_conflicts(packages_installed_): """Test finding conflicts.""" packages_installed_.return_value = [] component = Packages('test-component', ['package3', 'package4']) assert component.find_conflicts() is None packages_installed_.return_value = [] component = Packages('test-component', ['package3', 'package4'], conflicts=['package5', 'package6'], conflicts_action=Packages.ConflictsAction.IGNORE) assert component.find_conflicts() == [] packages_installed_.return_value = ['package1', 'package2'] component = Packages('test-component', ['package3', 'package4'], conflicts=['package1', 'package2'], conflicts_action=Packages.ConflictsAction.IGNORE) assert component.find_conflicts() == ['package1', 'package2'] @patch('plinth.package.refresh_package_lists') @patch('apt.Cache') @patch('pathlib.Path') def test_packages_is_available(path_class, cache, refresh_package_lists): """Test checking for available packages.""" package_cache = Mock() sources_dir = Mock() sources_dir.glob.return_value = [] sources_list = Mock() def path_side_effect(path): return { '/var/cache/apt/pkgcache.bin': package_cache, '/etc/apt/sources.list.d': sources_dir, '/etc/apt/sources.list': sources_list }[path] def get_cache_packages(packages): return { package: Mock(name=package, candidate='1.0') for package in packages } path_class.side_effect = path_side_effect # Packages found in cache component = Packages('test-component', ['package1', 'package2']) cache.return_value = get_cache_packages(['package1', 'package2']) assert component.is_available() path_class.assert_not_called() refresh_package_lists.assert_not_called() # Packages not found, cache exists and is fresh cache.return_value = get_cache_packages(['package1']) package_cache.exists.return_value = True package_cache.stat.return_value.st_mtime = time.time() sources_list.stat.return_value.st_mtime = time.time() - 100 assert not component.is_available() refresh_package_lists.assert_not_called() # Packages not found, cache does not exist cache.return_value = get_cache_packages(['package1']) package_cache.exists.return_value = False assert not component.is_available() refresh_package_lists.assert_called_once() # Packages not found, cache is stale because it older than 1 hour cache.return_value = get_cache_packages(['package1']) refresh_package_lists.reset_mock() package_cache.exists.return_value = True package_cache.stat.return_value.st_mtime = time.time() - 7200 assert not component.is_available() refresh_package_lists.assert_called_once() # Packages not found, cache is stale because it older than sources.list cache.return_value = get_cache_packages(['package1']) refresh_package_lists.reset_mock() package_cache.exists.return_value = True package_cache.stat.return_value.st_mtime = time.time() - 100 sources_list.stat.return_value.st_mtime = time.time() assert not component.is_available() refresh_package_lists.assert_called_once() # Packages not found, cache is stale, but packages found after refresh cache.side_effect = [ get_cache_packages(['package1']), get_cache_packages(['package1', 'package2']) ] refresh_package_lists.reset_mock() assert component.is_available() def test_packages_installed(): """Test packages_installed().""" # list as input assert len(packages_installed([])) == 0 assert len(packages_installed(['unknown-package'])) == 0 assert len(packages_installed(['python3'])) == 1 # tuples as input assert len(packages_installed(())) == 0 assert len(packages_installed(('unknown-package', ))) == 0 assert len(packages_installed(('python3', ))) == 1