tests/TestGuiStartup.py
Rama master · réplica de lectura del repositorio original.
# SPDX-License-Identifier: GPL-3.0-or-later
# Copyright (c) 2008-2026 Andrew Ziem.
#
# This work is licensed under the terms of the GNU GPL, version 3 or
# later. See the COPYING file in the top-level directory.
"""
Test case for module GuiStartup
"""
# These tests reach into internals on purpose.
# pylint: disable=protected-access
import os
import shutil
import stat
import subprocess
from unittest import mock
from bleachbit import IS_WINDOWS
import bleachbit
from bleachbit import GuiStartup
from bleachbit.Cleaner import register_cleaners
from bleachbit.GuiStartup import _has_selected_warning_option, _get_posix_permission_issues, _get_windows_permission_issues
from bleachbit.Options import options
from tests import common
class StubOptions:
"""Minimal stand-in for bleachbit.Options for testing."""
def __init__(self, *, first_start=True, shred=False, old_version=None):
"""Seed stub with values."""
self.values = {'first_start': first_start, 'shred': shred}
self._old_version = old_version
def get(self, key):
"""Return stored option value."""
return self.values.get(key)
def set(self, key, value):
"""Set a option value."""
self.values[key] = value
# pylint: disable-next=unused-argument
def get_tree(self, parent, child):
"""Return whether a tree option is selected."""
return False
class GuiStartupTestCase(common.BleachbitTestCase):
"""Tests for GuiStartup helper logic."""
def test_first_start_message_clears_flag(self):
"""Ensure first-start hint is emitted and flag gets cleared."""
stub_options = StubOptions()
with mock.patch.object(GuiStartup, 'options', stub_options), \
mock.patch.object(GuiStartup, 'unset_sslkeylogfile', return_value=False):
messages = GuiStartup.get_startup_messages(auto_exit=False)
self.assertTrue(
any('Access the application menu' in msg for msg, _ in messages))
self.assertFalse(stub_options.get('first_start'))
def test_outdated_build_notice_when_check_disabled(self):
"""An old build warns when the online update check is disabled"""
stub_options = StubOptions()
stub_options.values['check_online_updates'] = False
with mock.patch.object(GuiStartup, 'options', stub_options), \
mock.patch.object(GuiStartup, 'unset_sslkeylogfile', return_value=False), \
mock.patch('bleachbit.SystemInformation.get_outdated_build_notice',
return_value='OUTDATED BUILD NOTICE'):
messages = GuiStartup.get_startup_messages(auto_exit=False)
self.assertIn(('OUTDATED BUILD NOTICE', False), messages)
def test_outdated_build_notice_when_check_enabled(self):
"""An old build does not warn while the online check is pending"""
stub_options = StubOptions()
stub_options.values['check_online_updates'] = True
with mock.patch.object(GuiStartup, 'options', stub_options), \
mock.patch.object(GuiStartup, 'unset_sslkeylogfile', return_value=False), \
mock.patch('bleachbit.online_update_notification_enabled', True), \
mock.patch('bleachbit.SystemInformation.get_outdated_build_notice',
return_value='OUTDATED BUILD NOTICE'):
messages = GuiStartup.get_startup_messages(auto_exit=False)
self.assertNotIn(('OUTDATED BUILD NOTICE', False), messages)
def test_outdated_build_notice_when_auto_exit(self):
"""--exit skips the online check, so an old build still warns"""
stub_options = StubOptions()
stub_options.values['check_online_updates'] = True
with mock.patch.object(GuiStartup, 'options', stub_options), \
mock.patch.object(GuiStartup, 'unset_sslkeylogfile', return_value=False), \
mock.patch('bleachbit.online_update_notification_enabled', True), \
mock.patch('bleachbit.SystemInformation.get_outdated_build_notice',
return_value='OUTDATED BUILD NOTICE'):
messages = GuiStartup.get_startup_messages(auto_exit=True)
self.assertIn(('OUTDATED BUILD NOTICE', False), messages)
def test_has_selected_warning_option(self):
"""Test the function _has_selected_warning_option()"""
# Register cleaners to populate backends
list(register_cleaners())
# Each test case starts with fresh options, so with
# nothing selected, it should return False.
self.assertFalse(_has_selected_warning_option())
# Enable a safe option.
options.set_tree('system', 'tmp', True)
self.assertFalse(_has_selected_warning_option())
# Enable an option with a warning.
options.set_tree('system', 'empty_space', True)
self.assertTrue(_has_selected_warning_option())
# Clean up
options.set_tree('system', 'tmp', False)
options.set_tree('system', 'empty_space', False)
def test_missing_requests(self):
"""Missing requests should be reported but not crash GuiStartup."""
with common.mock_missing_package(
'requests',
clear_prefixes=('bleachbit.Network', 'bleachbit.Update', 'bleachbit.GuiStartup')):
# `import x.y as Y` is deliberate: mock_missing_package drops the
# submodule from sys.modules but leaves the parent attribute, so
# `from x import Y` would hand back the stale module.
# pylint: disable=consider-using-from-import
# pylint: disable=reimported
# pylint: disable-next=redefined-outer-name
import bleachbit.GuiStartup as GuiStartup
missing = GuiStartup._get_missing_dependencies()
self.assertIn('requests', missing)
@common.skipIfWindows
def test_is_config_writable(self):
"""Test read-only configuration file"""
o = bleachbit.Options.Options()
o.close()
self.assertExists(bleachbit.options_file)
if common.have_root():
# root bypasses file permission checks, so use chattr +i
subprocess.run(
['chattr', '+i', bleachbit.options_file], check=True)
else:
os.chmod(bleachbit.options_file, stat.S_IRUSR |
stat.S_IRGRP | stat.S_IROTH)
try:
issues = GuiStartup._get_config_permission_issues()
finally:
if common.have_root():
subprocess.run(
['chattr', '-i', bleachbit.options_file], check=True)
os.unlink(bleachbit.options_file)
self.assertNotExists(bleachbit.options_file)
self.assertIsInstance(issues, list)
self.assertGreater(len(issues), 0)
self.assertTrue(any('Write error' in issue for issue in issues),
f"Expected 'Write error' in issues: {issues}")
def test_config_permission_issues_normal(self):
"""Test that a normal writable config file reports no issues."""
o = bleachbit.Options.Options()
o.close()
self.assertExists(bleachbit.options_file)
issues = GuiStartup._get_config_permission_issues()
os.unlink(bleachbit.options_file)
self.assertNotExists(bleachbit.options_file)
self.assertFalse(issues)
def test_config_permission_nonissue_missing_file(self):
"""A missing config file must not be reported as an error.
Unlike another test, this scenario has an options directory
that allows creating the missing config file.
"""
if os.path.exists(bleachbit.options_file):
os.unlink(bleachbit.options_file)
self.assertNotExists(bleachbit.options_file)
issues = GuiStartup._get_config_permission_issues()
self.assertFalse(issues)
@common.also_with_sudo
def test_config_permission_nonissue_missing_dir(self):
"""A missing options directory (fresh profile) is created without error."""
new_dir = os.path.join(self.mkdtemp(), 'a', 'bleachbit')
new_file = os.path.join(new_dir, 'bleachbit.ini')
with mock.patch('bleachbit.options_dir', new_dir), \
mock.patch('bleachbit.options_file', new_file):
self.assertFalse(GuiStartup._get_config_permission_issues())
self.assertExists(new_dir)
self.assertExists(new_file)
if not IS_WINDOWS:
real_uid = bleachbit.General.get_real_uid()
self.assertEqual(os.stat(new_dir).st_uid, real_uid)
self.assertEqual(os.stat(new_file).st_uid, real_uid)
@common.skipIfWindows
def test_config_permission_missing_file_sudo_chown(self):
"""Creating a missing config file in sudo mode chowns it to the user."""
new_dir = self.mkdtemp()
new_file = os.path.join(new_dir, 'bleachbit.ini')
with mock.patch('bleachbit.options_dir', new_dir), \
mock.patch('bleachbit.options_file', new_file), \
mock.patch('bleachbit.General.sudo_mode', return_value=True), \
mock.patch('bleachbit.General.chownself') as mock_chown:
self.assertFalse(GuiStartup._get_config_permission_issues())
mock_chown.assert_called_once_with(new_file)
mock_chown.reset_mock()
# Existing file should not be re-chowned by the probe
self.assertFalse(GuiStartup._get_config_permission_issues())
mock_chown.assert_not_called()
@common.skipIfWindows
def test_config_permission_issue_missing_dir_non_writeable_parent(self):
"""Missing options dir under a non-writable parent reports an error."""
non_writable_parent = self.mkdir('not_writeable_parent')
missing_dir = os.path.join(non_writable_parent, 'bleachbit')
missing_file = os.path.join(missing_dir, 'bleachbit.ini')
use_chattr = common.have_root()
try:
if use_chattr:
subprocess.run(
['chattr', '+i', non_writable_parent], check=True)
else:
os.chmod(non_writable_parent, stat.S_IRUSR | stat.S_IXUSR)
with mock.patch('bleachbit.options_dir', missing_dir), \
mock.patch('bleachbit.options_file', missing_file):
issues = GuiStartup._get_config_permission_issues()
finally:
if use_chattr:
subprocess.run(
['chattr', '-i', non_writable_parent], check=True)
else:
os.chmod(non_writable_parent, stat.S_IRWXU)
shutil.rmtree(non_writable_parent)
self.assertNotExists(missing_dir)
self.assertIsInstance(issues, list)
self.assertTrue(
any('Cannot create directory' in issue for issue in issues),
f"Expected 'Cannot create directory' in issues: {issues}")
self.assertIn(
f'Nearest existing parent directory: {non_writable_parent}', issues)
self.assertTrue(
any(issue.startswith('Current user:') for issue in issues),
f"Expected 'Current user:' in issues: {issues}")
@common.skipIfWindows
def test_config_permission_issue_non_writeable_options_dir(self):
"""Config does not exist and options dir is not writeable"""
non_writable_dir = self.mkdir('not_writeable_options')
non_writable_file = os.path.join(non_writable_dir, 'bleachbit.ini')
use_chattr = common.have_root()
try:
if use_chattr:
# Root bypasses chmod, so use chattr +i instead
subprocess.run(['chattr', '+i', non_writable_dir], check=True)
else:
os.chmod(non_writable_dir, stat.S_IRUSR | stat.S_IXUSR)
self.assertNotExists(non_writable_file)
with mock.patch('bleachbit.options_dir', non_writable_dir), \
mock.patch('bleachbit.options_file', non_writable_file):
issues = GuiStartup._get_config_permission_issues()
finally:
if use_chattr:
subprocess.run(['chattr', '-i', non_writable_dir], check=True)
else:
os.chmod(non_writable_dir, stat.S_IRWXU)
shutil.rmtree(non_writable_dir)
self.assertIsInstance(issues, list)
self.assertGreater(len(issues), 0)
self.assertTrue(any('Write error' in issue for issue in issues),
f"Expected 'Write error' in issues: {issues}")
@common.also_with_sudo
def test_permission_issues_normal_file(self):
"""Test ownership check on a normal file owned by the user."""
path = self.write_file('check_me')
fstat = os.stat(path)
if IS_WINDOWS:
has_error, lines = _get_windows_permission_issues(path)
else:
has_error, lines = _get_posix_permission_issues(fstat, path)
self.assertFalse(has_error)
self.assertTrue(any('File owner:' in line for line in lines))
self.assertTrue(any('Current user:' in line for line in lines))
@common.skipUnlessWindows
def test_get_windows_user_info(self):
"""Test _get_windows_user_info function."""
current_sid, current_name, group_sids = GuiStartup._get_windows_user_info()
self.assertIsNotNone(current_sid)
self.assertIsNotNone(current_name)
self.assertIsNotNone(group_sids)
self.assertIsInstance(current_sid, str)
self.assertIsInstance(current_name, str)
self.assertIsInstance(group_sids, set)
self.assertEqual(current_name, os.environ.get('USERNAME', ''))
@common.skipUnlessWindows
def test_get_windows_file_owner(self):
"""Test _get_windows_file_owner function."""
owner_sid_str, owner_name = GuiStartup._get_windows_file_owner(
self.tempdir)
self.assertIsNotNone(owner_sid_str)
self.assertIsNotNone(owner_name)
self.assertIsInstance(owner_sid_str, str)
self.assertIsInstance(owner_name, str)
# Elevated processes get Administrators as the owner, others get the
# user, so accept either, like _get_windows_permission_issues does.
current_sid, _name, group_sids = GuiStartup._get_windows_user_info()
self.assertIn(owner_sid_str, {current_sid} | group_sids)
@common.skipUnlessWindows
def test_lookup_account_name_or_sid_unresolvable(self):
"""An unresolvable SID falls back to its string form (issue #2271).
Reproduces the dual-boot / shared NTFS scenario where
``LookupAccountSid`` fails because the SID belongs to an
account from the other Windows installation. The helper must
not raise; it should return the SID string so the ownership
comparison can still proceed by SID.
"""
import win32security
bogus = 'S-1-5-21-1234567890-1234567890-1234567890-1234'
sid = win32security.ConvertStringSidToSid(bogus)
name, _ = GuiStartup._lookup_account_name_or_sid(sid)
self.assertEqual(name, bogus)
@common.skipUnlessWindows
def test_config_permission_issue_protected_dacl(self):
"""A 0o700 config directory (issue #2401) is flagged until repaired."""
import win32security # pylint: disable=import-outside-toplevel
protected_dir = os.path.join(self.tempdir, 'protected_options')
os.mkdir(protected_dir, 0o700)
self.addCleanup(shutil.rmtree, protected_dir, ignore_errors=True)
protected_file = os.path.join(protected_dir, 'bleachbit.ini')
self.write_file(protected_file, b'[bleachbit]\n')
with mock.patch('bleachbit.options_dir', protected_dir), \
mock.patch('bleachbit.options_file', protected_file), \
mock.patch('bleachbit.Windows.shell.IsUserAnAdmin', return_value=False):
# Owned by the current user, so it works and is not flagged.
# When elevated the owner is Administrators, so read it from the file.
owner_sid = win32security.GetFileSecurity(
protected_dir, win32security.OWNER_SECURITY_INFORMATION).GetSecurityDescriptorOwner()
with mock.patch('bleachbit.Windows._current_user_sid', return_value=owner_sid):
self.assertFalse(GuiStartup._get_config_permission_issues())
# Simulate an elevated first run: the owner is not the user
other_sid = win32security.ConvertStringSidToSid('S-1-5-32-545')
with mock.patch('bleachbit.Windows._current_user_sid', return_value=other_sid):
issues = GuiStartup._get_config_permission_issues()
self.assertIsInstance(issues, list)
self.assertTrue(
any('issue #2401' in issue for issue in issues),
f'Expected issue #2401 in issues: {issues}')
with mock.patch('bleachbit.portable_mode', True):
self.assertFalse(
GuiStartup._get_config_permission_issues())
with mock.patch('bleachbit.Windows.shell.IsUserAnAdmin', return_value=True):
self.assertFalse(
GuiStartup._get_config_permission_issues())
bleachbit.Windows.fix_appdata_acl(protected_dir)
self.assertFalse(GuiStartup._get_config_permission_issues())