Ctrl K
ComunidadGuías…

tests/TestCookie.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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 Cookie
"""

import contextlib
import hashlib
import json
import os
import sqlite3
import unittest
import shutil
from concurrent.futures import ThreadPoolExecutor
from unittest import mock

import bleachbit
from bleachbit import Command, Cookie
from bleachbit.Action import Cookie as CookieAction
from bleachbit.Cookie import (
    COOKIE_KEEP_LIST_FILENAME, _checkpoint_wal, _estimate_in_memory_size,
    load_keep_list)
from bleachbit.Options import options
from bleachbit.FileUtilities import SQLITE_BUSY_TIMEOUT, execute_sqlite3
from tests import common


DEFAULT_COOKIE_FIXTURES = (
    ('example.com', 'session_id'),
    ('google.com', 'pref'),
    ('github.com', 'auth'),
)

DOMAIN_MATCHING_CASES = (
    ('example.com', 'example.com', True, 'Exact host match'),
    ('example.com', '.example.com', True,
     'Domain cookie canonicalizes to base host'),
    ('example.com', 'shop.example.com', True,
     'Subdomain should be preserved when base is keeplisted'),
    ('example.com', '.shop.example.com', True,
     'Firefox/Chromium store domain cookies with dots'),
    ('example.com', 'thisisanexample.com', False,
     'Substring matches must not count'),
    ('example.com', 'example.com.attacker.net', False,
     'Extra label to the right is a different domain'),
    ('example.com', 'shop-example.com', False,
     'Hyphenated host is unrelated'),
    ('www.example.com', 'example.com', False,
     'Host-only cookies should not match parent domain'),
    ('.example.com', 'example.com', True,
     'Leading dots in keep list should be ignored'),
    ('.example.com', 'www.example.com', True,
     'Leading dots in keep list should match subdomains'),
    ('.example.com', 'fooexample.com', False,
     'Canonical registrable domain mismatch'),
    ('a_b.com', 'x.axb.com', False,
     'Underscore is not a wildcard'),
)


# Additional coverage:
# - Non-SQLite file with non-empty keep list should not be deleted (raises ValueError)
# - SQLite DB with unknown table and non-empty keep list should not be deleted (raises ValueError)
# - When no hosts match keep list, all rows are deleted and the database file is removed

class CookieTestCase(common.BleachbitTestCase):
    """Test cases for Cookie module"""

    def setUp(self):
        """Set up test fixtures"""
        super().setUp()
        # A directory per test, so fixed file names never collide
        self.tempdir = self.mkdtemp()
        # tearDown reloads Options, so this doesn't leak between tests
        options.set('shred', True)
        # remove the shared cookie keep-list file after every test
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        self.addCleanup(lambda: os.path.exists(
            keep_path) and os.remove(keep_path))

    def _get_file_hash(self, filepath):
        """Calculate SHA-256 hash of a file"""
        with open(filepath, 'rb') as f:
            return hashlib.sha256(f.read()).digest()

    def _sample_cookies(self, dotted=False):
        """Return a reusable copy of the default cookie fixtures"""
        cookies = DEFAULT_COOKIE_FIXTURES
        if dotted:
            return [
                (host if host.startswith('.') else f'.{host}', name)
                for host, name in cookies
            ]
        return list(cookies)

    def _create_chrome_cookies_db(self, cookies=None):
        """Create a Chrome cookies database for testing

        Args:
            cookies: Iterable of (host_key, name) tuples. If None, uses
                default test cookies.

        Returns:
            str: Path to the created cookies database
        """
        if cookies is None:
            cookies = self._sample_cookies()

        path = os.path.join(self.tempdir, 'chrome_cookies.db')
        for suffix in ('', '-wal', '-shm'):
            if os.path.exists(path + suffix):
                os.unlink(path + suffix)
        sql = '''
            CREATE TABLE cookies (
                host_key TEXT NOT NULL,
                name TEXT NOT NULL
            )
        '''
        execute_sqlite3(path, sql)

        conn = sqlite3.connect(path)
        try:
            cursor = conn.cursor()
            for host_key, name in cookies:
                cursor.execute(
                    'INSERT INTO cookies (host_key, name) VALUES (?, ?)',
                    (host_key, name),
                )
            conn.commit()
        finally:
            conn.close()

        return path

    def _find_live_firefox_cookies_file(self):
        """Return path to a real Firefox cookies.sqlite if one exists"""
        search_roots = [
            os.path.expanduser('~/snap/firefox/common/.mozilla/firefox'),
            os.path.expanduser('~/.mozilla/firefox'),
        ]
        appdata = os.environ.get('APPDATA')
        if appdata:
            search_roots.append(os.path.join(
                appdata, 'Mozilla', 'Firefox', 'Profiles'))

        for root in search_roots:
            if not root or not os.path.isdir(root):
                continue
            for dirpath, _dirnames, filenames in os.walk(root):
                if 'cookies.sqlite' in filenames:
                    return os.path.join(dirpath, 'cookies.sqlite')
        return None

    def _create_firefox_cookies_db(self, cookies=None):
        """Create a Firefox cookies database for testing

        Args:
            cookies: Iterable of cookie descriptors. Accepts:
                     * plain host strings
                     * (host, name) tuples
                     If None, uses default test cookies.

        Returns:
            str: Path to the created cookies database
        """
        if cookies is None:
            cookies = self._sample_cookies(dotted=True)

        path = os.path.join(self.tempdir, 'firefox_cookies.db')
        for suffix in ('', '-wal', '-shm'):
            if os.path.exists(path + suffix):
                os.unlink(path + suffix)
        sql = '''
            CREATE TABLE moz_cookies (
                id INTEGER PRIMARY KEY,
                host TEXT NOT NULL,
                name TEXT NOT NULL,
                CONSTRAINT moz_uniqueid UNIQUE (host, name)
            )
        '''
        execute_sqlite3(path, sql)

        conn = sqlite3.connect(path)
        try:
            cursor = conn.cursor()
            for cookie in cookies:
                host = name = None
                if isinstance(cookie, str):
                    host, name = cookie, ''
                elif isinstance(cookie, (list, tuple)):
                    if len(cookie) >= 2:
                        host, name = cookie[:2]
                    elif len(cookie) == 1:
                        host, name = cookie[0], ''
                if host is None:
                    raise ValueError(
                        'Unsupported cookie format for Firefox test fixture')
                cursor.execute(
                    'INSERT INTO moz_cookies (host, name) VALUES (?, ?)', (host, name))
            conn.commit()
        finally:
            conn.close()

        return path

    def test_delete_cookie_value_error(self):
        """Empty keep list should raise ValueError (API requires non-empty set)"""
        path = self._create_chrome_cookies_db()

        # Get file hash before operation
        original_hash = self._get_file_hash(path)

        # Test scenarios that should raise ValueError
        scenarios = [
            # Scenario 1: Empty allowlist
            (set(), "Empty keep list should raise ValueError"),
            # Scenario 2: None allowlist
            (None, "None keep list should raise ValueError"),
        ]

        for keep_list, description in scenarios:
            with self.subTest(description=description):
                with self.assertRaises(ValueError):
                    Cookie.delete_cookies(path, keep_list, really_delete=True)
                self.assertExists(path)
                new_hash = self._get_file_hash(path)
                self.assertEqual(original_hash, new_hash,
                                 'Database file was modified')

    def test_delete_cookie_bad_file(self):
        """Empty keep list should raise ValueError (API requires non-empty set)"""
        path_missing = os.path.join(self.tempdir, 'nonexistent.db')
        path_non_sqlite = self.write_file(
            'experiment_626.dna', text='not sqlite3')
        path_wrong_sqlite = os.path.join(self.tempdir, 'wrong_sqlite.db')

        execute_sqlite3(
            path_wrong_sqlite, "CREATE TABLE not_browser_related (id INTEGER PRIMARY KEY)")

        scenarios = [
            (path_missing, "Non-existent file", ValueError),
            (path_non_sqlite, "Non-SQLite file", sqlite3.DatabaseError),
            (path_wrong_sqlite, "SQLite file with wrong table", ValueError),
        ]

        for test_path, description, expected_exception in scenarios:
            with self.subTest(description=description):
                original_hash = None
                if test_path != path_missing:
                    original_hash = self._get_file_hash(test_path)
                with self.assertRaises(expected_exception):
                    Cookie.delete_cookies(
                        test_path, {'example.com'}, really_delete=True)

                if test_path == path_missing:
                    self.assertNotExists(test_path)
                else:
                    self.assertExists(test_path)
                    new_hash = self._get_file_hash(test_path)
                    self.assertEqual(original_hash, new_hash,
                                     'Database file was modified')

    def test_delete_cookies_chrome_with_keeplist(self):
        """Test selectively deleting Chrome cookies with keep list"""
        path = self._create_chrome_cookies_db()

        # Test deletion with keep list (keep google.com)
        keep_list = ['google.com']
        result = Cookie.delete_cookies(
            path, set(keep_list), really_delete=True)

        # example.com and github.com deleted
        self.assertEqual(result['total_deleted'], 2)
        self.assertEqual(result['total_kept'], 1)    # google.com kept
        self.assertFalse(result['skipped'])
        self.assertFalse(result['whole_file_deleted'])
        self.assertGreaterEqual(result['file_size_reduction'], 0)

    def test_delete_cookies_wal_with_open_reader(self):
        """SQLite must own WAL/SHM cleanup while another connection is open."""
        for keep_list in ({'google.com'}, {'not-kept.invalid'}):
            for pinned in (False, True):
                with self.subTest(keep_list=keep_list, pinned=pinned):
                    path = self._create_chrome_cookies_db()
                    with contextlib.closing(sqlite3.connect(
                            path, isolation_level=None)) as reader:
                        self.assertEqual(reader.execute(
                            'PRAGMA journal_mode=WAL').fetchone()[0], 'wal')
                        if pinned:
                            reader.execute('BEGIN')
                        before = reader.execute(
                            'SELECT host_key FROM cookies ORDER BY host_key').fetchall()

                        preview = Cookie.delete_cookies(path, keep_list)
                        self.assertEqual(
                            preview['file_size_estimation_method'], 'in_memory')
                        with mock.patch('bleachbit.Cookie.FileUtilities.delete') as delete:
                            result = Cookie.delete_cookies(
                                path, keep_list, really_delete=True)

                        delete.assert_not_called()
                        expected = [
                            (host,) for host in ('google.com',) if host in keep_list]
                        self.assertFalse(result['skipped'])
                        self.assertFalse(result['whole_file_deleted'])
                        self.assertEqual(result['total_kept'], len(expected))
                        self.assertEqual(
                            result['total_deleted'], 3 - len(expected))
                        self.assertExists(path)
                        self.assertExists(path + '-shm')
                        self.assertEqual(reader.execute(
                            'SELECT host_key FROM cookies ORDER BY host_key').fetchall(),
                            before if pinned else expected)

                        with contextlib.closing(sqlite3.connect(path)) as fresh:
                            self.assertEqual(fresh.execute(
                                'SELECT host_key FROM cookies').fetchall(), expected)
                            self.assertEqual(fresh.execute(
                                'PRAGMA integrity_check').fetchone()[0], 'ok')

                    with contextlib.closing(sqlite3.connect(path)) as reopened:
                        self.assertEqual(reopened.execute(
                            'SELECT host_key FROM cookies').fetchall(), expected)

    def test_delete_cookies_wal_disk_size(self):
        """Reported disk savings match the on-disk footprint after close."""
        for shred in (False, True):
            for keep_list in ({'google.com'}, {'not-kept.invalid'}):
                with self.subTest(shred=shred, keep_list=keep_list):
                    options.set('shred', shred)
                    path = self._create_chrome_cookies_db()
                    with contextlib.closing(sqlite3.connect(path)) as conn:
                        conn.execute('PRAGMA journal_mode=WAL')
                    Cookie.detect_browser(path)
                    original_size = sum(
                        os.path.getsize(path + suffix)
                        for suffix in ('', '-wal', '-shm')
                        if os.path.exists(path + suffix))

                    result = Cookie.delete_cookies(
                        path, keep_list, really_delete=True)

                    self.assertFalse(result['skipped'])
                    self.assertFalse(result['whole_file_deleted'])
                    final_size = sum(
                        os.path.getsize(path + suffix)
                        for suffix in ('', '-wal', '-shm')
                        if os.path.exists(path + suffix))
                    self.assertEqual(result['file_size_reduction'],
                                     original_size - final_size)
                    # SQLite owns the sidecars and may leave the -shm
                    # wal-index behind, but the WAL must not retain
                    # uncheckpointed data.
                    wal_size = (os.path.getsize(path + '-wal')
                                if os.path.exists(path + '-wal') else 0)
                    self.assertEqual(wal_size, 0)

    def test_checkpoint_wal_busy(self):
        """A blocked checkpoint must warn that deleted data remains in the WAL."""
        path = self._create_chrome_cookies_db()
        with contextlib.closing(sqlite3.connect(path)) as reader, \
                contextlib.closing(sqlite3.connect(
                    path, timeout=SQLITE_BUSY_TIMEOUT)) as writer:
            reader.execute('PRAGMA journal_mode=WAL')
            reader.execute('BEGIN')
            reader.execute('SELECT * FROM cookies').fetchall()
            writer.execute(
                'DELETE FROM cookies WHERE host_key = ?', ('example.com',))
            writer.commit()
            isolation = writer.isolation_level

            with self.assertLogs(Cookie.logger, level='WARNING') as logs:
                _checkpoint_wal(writer, path)

            self.assertIn('WAL', logs.output[0])
            self.assertIn(path, logs.output[0])
            self.assertEqual(writer.isolation_level, isolation)
            self.assertGreater(os.path.getsize(path + '-wal'), 0)

    def test_non_sqlite_file_with_keeplist_raises(self):
        """Non-SQLite file should raise ValueError and not be deleted"""
        # First create a valid cookies db to get the path format right
        path = self._create_chrome_cookies_db()
        # Now remove it and create a non-sqlite file
        os.unlink(path)
        path = os.path.join(self.tempdir, 'not_sqlite.txt')
        with open(path, 'w', encoding='utf-8') as f:
            f.write('not a database')

        with self.assertRaises(sqlite3.DatabaseError):
            Cookie.delete_cookies(path, {'example.com'}, really_delete=True)

        self.assertTrue(os.path.exists(path))

    def test_sqlite_unknown_table_with_keeplist_raises(self):
        """SQLite DB without known cookies table should raise ValueError"""
        path = os.path.join(self.tempdir, 'unknown_table.db')
        conn = sqlite3.connect(path)
        cur = conn.cursor()
        cur.execute(
            'CREATE TABLE something_else (id INTEGER PRIMARY KEY, host TEXT)')
        conn.commit()
        conn.close()

        with self.assertRaises(ValueError):
            Cookie.delete_cookies(path, {'example.com'}, really_delete=True)

        self.assertTrue(os.path.exists(path))

    def test_delete_cookies_when_no_hosts_match_kept(self):
        """If no hosts match keep list, all rows are deleted and file is removed"""
        path = self._create_chrome_cookies_db()

        result = Cookie.delete_cookies(
            path, {'no-such-host.example'}, really_delete=True)

        self.assertEqual(result['total_kept'], 0)
        self.assertEqual(result['total_deleted'], 3)
        self.assertFalse(result['skipped'])
        self.assertTrue(result['whole_file_deleted'])
        self.assertGreaterEqual(result['file_size_reduction'], 0)

        # Verify database file was removed
        self.assertFalse(os.path.exists(path))

    def test_delete_cookies_firefox_with_keeplist(self):
        """Test selectively deleting Firefox cookies with keeplist"""
        path = self._create_firefox_cookies_db()

        # Test deletion with keep list
        keep_list = ['google.com']
        result = Cookie.delete_cookies(
            path, set(keep_list), really_delete=True)

        # example.com and github.com deleted
        self.assertEqual(result['total_deleted'], 2)
        self.assertEqual(result['total_kept'], 1)    # google.com kept
        self.assertFalse(result['skipped'])
        self.assertFalse(result['whole_file_deleted'])
        self.assertGreaterEqual(result['file_size_reduction'], 0)

        # Verify the database still exists and has the right cookies
        self.assertTrue(os.path.exists(path))

        conn = sqlite3.connect(path)
        cursor = conn.cursor()
        remaining_cookies = cursor.execute(
            'SELECT host FROM moz_cookies').fetchall()
        conn.close()

        self.assertEqual(len(remaining_cookies), 1)
        # Firefox stores domain cookies with a leading dot (e.g., '.google.com').
        self.assertEqual(remaining_cookies[0][0].lstrip('.'), 'google.com')

    def test_delete_firefox_live(self):
        """Test deleting Firefox cookies from a real database"""
        live_path = self._find_live_firefox_cookies_file()
        if not live_path:
            self.skipTest(
                'Skipping live Firefox test because cookies.sqlite was not found')

        def run_test_scenario(random_count):
            """Helper to test a cookie deletion scenario"""
            self.assertGreaterEqual(random_count, 0)
            temp_db = os.path.join(
                self.tempdir, f'firefox_live_copy{random_count}.sqlite')
            shutil.copy2(live_path, temp_db)

            # Copy WAL/SHM files if present to keep the database consistent
            for suffix in ('-wal', '-shm'):
                aux_src = f"{live_path}{suffix}"
                if os.path.exists(aux_src):
                    shutil.copy2(aux_src, f"{temp_db}{suffix}")

            # Add a non-existent domain to keep list to test filtering
            doesnotexist = 'doesnotexist1234567890.com'
            if random_count:
                conn = sqlite3.connect(temp_db)
                cursor = conn.cursor()
                random_hosts = cursor.execute(
                    f'SELECT DISTINCT host FROM moz_cookies ORDER BY RANDOM() LIMIT {random_count}').fetchall()
                conn.close()
                if not random_hosts:
                    self.skipTest('No hosts found in live Firefox database')
                keep_list = {host[0].lstrip('.') for host in random_hosts}
            else:
                keep_list = {doesnotexist}

            preview_result = Cookie.delete_cookies(
                temp_db, keep_list, really_delete=False)
            self.assertIn('total_deleted', preview_result)
            self.assertFalse(preview_result['skipped'])
            self.assertTrue(os.path.exists(temp_db))

            delete_result = Cookie.delete_cookies(
                temp_db, keep_list, really_delete=True)
            self.assertIn('total_deleted', delete_result)
            self.assertFalse(delete_result['skipped'])

            # Check file size reduction accuracy
            size_diff = abs(
                preview_result['file_size_reduction'] - delete_result['file_size_reduction'])
            # The estimate is accurate with shredding enabled, which is
            # how this test is run.
            self.assertLessEqual(
                size_diff, 1024, f"Size difference {size_diff:,} exceeds tolerance")

            if delete_result.get('whole_file_deleted'):
                self.assertFalse(os.path.exists(temp_db))
            else:
                self.assertTrue(os.path.exists(temp_db))
                os.unlink(temp_db)

        # Test scenario 1: Delete all cookies (keep non-existent domain)
        run_test_scenario(0)
        # Test scenario 2: Delete several random cookies
        run_test_scenario(10)
        run_test_scenario(50)

    def test_preview_cookies_deletion_chrome(self):
        """Test previewing Chrome cookie deletion"""
        path = self._create_chrome_cookies_db()

        # Test preview with keep list
        keep_list = ['google.com']
        result = Cookie.delete_cookies(
            path, set(keep_list), really_delete=False)

        self.assertEqual(result['total_deleted'], 2)
        self.assertEqual(result['total_kept'], 1)
        self.assertFalse(result['skipped'])
        self.assertFalse(result['whole_file_deleted'])
        self.assertGreaterEqual(result['file_size_reduction'], 0)

        # Verify original database is unchanged
        conn = sqlite3.connect(path)
        cursor = conn.cursor()
        all_cookies = cursor.execute('SELECT host_key FROM cookies').fetchall()
        conn.close()

        self.assertEqual(len(all_cookies), 3)  # All cookies still there

    def test_estimate_in_memory_size_locked(self):
        """A lock acquired before backup must not make preview retry forever."""
        path = self._create_chrome_cookies_db()

        def estimate():
            with contextlib.closing(sqlite3.connect(
                    path, timeout=SQLITE_BUSY_TIMEOUT)) as source:
                return _estimate_in_memory_size(
                    source, 'cookies',
                    'DELETE FROM cookies WHERE host_key != ?', ('google.com',))

        with contextlib.closing(sqlite3.connect(path)) as holder, \
                ThreadPoolExecutor(max_workers=1) as executor:
            holder.execute('BEGIN EXCLUSIVE')
            future = executor.submit(estimate)
            try:
                self.assertIsNone(future.result(timeout=3))
            finally:
                holder.rollback()

    def test_preview_cookies_backup_unavailable(self):
        """Preview falls back to a ratio when the in-memory backup fails."""
        path = self._create_chrome_cookies_db()
        original_hash = self._get_file_hash(path)
        with mock.patch.object(Cookie, '_estimate_in_memory_size',
                               return_value=None) as estimate:
            result = Cookie.delete_cookies(path, {'google.com'})

        estimate.assert_called_once()
        self.assertEqual(result['total_deleted'], 2)
        self.assertEqual(result['total_kept'], 1)
        self.assertFalse(result['skipped'])
        self.assertEqual(result['file_size_estimation_method'], 'ratio')
        self.assertIsNone(result['file_size_reduction_in_memory'])
        self.assertEqual(result['file_size_reduction'],
                         result['file_size_reduction_ratio'])
        self.assertEqual(original_hash, self._get_file_hash(path))

    def test_keeplist_domain_matching_matrix(self):
        """Table-driven coverage for keep list domain semantics"""
        for selection, host_value, expect_keep, reason in DOMAIN_MATCHING_CASES:
            with self.subTest(selection=selection, host=host_value, reason=reason):
                cookies = [(host_value, 'token')]
                path = self._create_chrome_cookies_db(cookies)
                result = Cookie.delete_cookies(
                    path, {selection}, really_delete=False)

                if expect_keep:
                    self.assertEqual(result['total_kept'], 1, reason)
                    self.assertEqual(result['total_deleted'], 0)
                else:
                    self.assertEqual(result['total_kept'], 0, reason)
                    self.assertEqual(result['total_deleted'], 1)

    def test_delete_cookies_long_keep_list(self):
        """A keep list longer than SQLite's expression depth limit works"""
        path = self._create_chrome_cookies_db()
        keep_list = {f'd{i}.example' for i in range(1000)} | {'google.com'}
        for really_delete in (False, True):
            with self.subTest(really_delete=really_delete):
                result = Cookie.delete_cookies(
                    path, keep_list, really_delete=really_delete)
                self.assertFalse(result['skipped'])
                self.assertEqual(result['total_kept'], 1)
                self.assertEqual(result['total_deleted'], 2)

        conn = sqlite3.connect(path)
        try:
            hosts = conn.execute('SELECT host_key FROM cookies').fetchall()
        finally:
            conn.close()
        self.assertEqual(hosts, [('google.com',)])

    def test_preview_cookies_deletion_no_keeplist(self):
        """Test previewing cookie deletion with no keep list"""
        # Create test cookies
        cookies_data = [
            ('example.com', 'session_id'),
        ]

        path = self._create_chrome_cookies_db(cookies_data)

        # Test preview with no keep list (should preview whole file deletion)
        with self.assertRaises(ValueError):
            Cookie.delete_cookies(path, set(), really_delete=False)

    def test_nonexistent_file(self):
        """Test handling of nonexistent cookie files"""
        path = os.path.join(self.tempdir, 'nonexistent.db')

        # Both deletion and preview should raise ValueError for nonexistent files
        with self.assertRaises(ValueError):
            Cookie.delete_cookies(path, {'example.com'}, really_delete=True)
        with self.assertRaises(ValueError):
            Cookie.delete_cookies(path, {'example.com'}, really_delete=False)

    def test_browser_detection(self):
        """Test automatic browser detection"""
        # Test Chrome detection
        chrome_path = self._create_chrome_cookies_db()
        self.assertEqual(Cookie.detect_browser(
            chrome_path), ('cookies', 'host_key'))

        # Test Firefox detection
        firefox_path = self._create_firefox_cookies_db()
        self.assertEqual(Cookie.detect_browser(
            firefox_path), ('moz_cookies', 'host'))

    def test_browser_detection_locked(self):
        """A locked cookies database is reported as locked after one probe"""
        path = self._create_chrome_cookies_db()
        holder = sqlite3.connect(path, isolation_level=None)
        try:
            holder.execute('PRAGMA locking_mode=EXCLUSIVE')
            holder.execute('BEGIN EXCLUSIVE')
            with mock.patch('sqlite3.connect', side_effect=sqlite3.connect) as mock_connect, \
                    self.assertRaisesRegex(sqlite3.OperationalError, 'database is locked'):
                Cookie.detect_browser(path)
            self.assertEqual(mock_connect.call_count, 1)
        finally:
            holder.close()

    def test_empty_database(self):
        """Test handling of empty cookie databases"""
        # Create empty Chrome database
        path = self._create_chrome_cookies_db([])

        # Preview must not promise the whole file when nothing is deleted
        for really_delete in (False, True):
            with self.subTest(really_delete=really_delete):
                result = Cookie.delete_cookies(
                    path, {'example.com'}, really_delete=really_delete)

                self.assertEqual(result['total_deleted'], 0)
                self.assertEqual(result['total_kept'], 0)
                self.assertFalse(result['skipped'])
                self.assertFalse(result['whole_file_deleted'])
                self.assertEqual(result['file_size_reduction'], 0)
        self.assertExists(path)

    def test_delete_cookies_raises_sqlite_error_when_cleaning(self):
        """A SQLite error while cleaning propagates instead of being skipped"""
        path = self._create_chrome_cookies_db()
        conn = sqlite3.connect(path)
        try:
            conn.execute("CREATE TRIGGER no_delete BEFORE DELETE ON cookies "
                         "BEGIN SELECT RAISE(ABORT, 'refused'); END")
            conn.commit()
        finally:
            conn.close()

        with self.assertRaises(sqlite3.Error):
            Cookie.delete_cookies(path, {'google.com'}, really_delete=True)

        conn = sqlite3.connect(path)
        try:
            count = conn.execute('SELECT COUNT(*) FROM cookies').fetchone()[0]
        finally:
            conn.close()
        self.assertEqual(count, 3)

    def test_cookie_action_raises_on_failure(self):
        """A failed cookie clean reaches the Worker as an error"""
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        os.makedirs(bleachbit.options_dir, exist_ok=True)
        self.write_file(keep_path, json.dumps(['example.com']), mode='w')
        path = os.path.join(self.tempdir, 'unknown_table.db')
        execute_sqlite3(path, 'CREATE TABLE something_else (id INTEGER)')

        cmds = list(self._make_cookie_action(path).get_commands())
        self.assertEqual(len(cmds), 1)
        with self.assertRaises(ValueError):
            list(cmds[0].execute(really_delete=True))
        self.assertExists(path)

    def test_list_unique_cookies_backends_change(self):
        """Cookie discovery survives the cleaner list changing under it"""
        from bleachbit import Cleaner
        chrome = self._create_chrome_cookies_db([('chrome.example', 'n')])
        firefox = self._create_firefox_cookies_db([('.firefox.example', 'n')])

        def make_cleaner(path, on_get_paths=None):
            def get_paths():
                if on_get_paths:
                    on_get_paths()
                yield path
            action = mock.Mock(action_key='cookie', get_paths=get_paths)
            return mock.Mock(actions=[('cookies', action)])

        # The GUI thread refills backends while discovery runs.
        backends = {
            'a': make_cleaner(chrome, lambda: Cleaner.backends.pop('b')),
            'b': make_cleaner(firefox),
        }
        with mock.patch.dict(Cleaner.backends, backends, clear=True):
            hosts = Cookie.list_unique_cookies()
        self.assertEqual(hosts, ['.firefox.example', 'chrome.example'])

    def _make_cookie_action(self, path=None):
        """Create a Cookie action instance with a mock XML element."""
        if path is None:
            path = os.path.join(self.tempdir, 'nonexistent')
        elem = mock.MagicMock()
        elem.getAttribute.return_value = ''
        elem.getAttribute.side_effect = lambda name: {
            'command': 'cookie',
            'search': 'file',
            'path': path,
        }.get(name, '')
        return CookieAction(elem)

    def _assert_cookie_action_skipped(self, action):
        """An unreadable keep list yields one command that fails"""
        cmds = list(action.get_commands())
        self.assertEqual(len(cmds), 1)
        self.assertIsInstance(cmds[0], Command.Fail)
        self.assertIn(COOKIE_KEEP_LIST_FILENAME, str(cmds[0]))
        for really_delete in (False, True):
            with self.assertRaises(RuntimeError):
                list(cmds[0].execute(really_delete))

    def test_load_keep_list_file_not_found(self):
        """FileNotFoundError should return empty set (no keep list configured)"""
        # Ensure the file doesn't exist
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        if os.path.exists(keep_path):
            os.remove(keep_path)
        result = load_keep_list()
        self.assertEqual(result, set())

    def test_load_keep_list_valid_json(self):
        """Valid JSON keep list should return the expected domains"""
        keep_data = ["example.com", "example.org"]
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        os.makedirs(bleachbit.options_dir, exist_ok=True)
        with open(keep_path, 'w', encoding='utf-8') as f:
            json.dump(keep_data, f)
        result = load_keep_list()
        self.assertEqual(result, {'example.com', 'example.org'})

    def test_load_keep_list_whitespace_preserves_cookies(self):
        """Padded saved domains must preserve their cookies when cleaning."""
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        self.write_file(keep_path, text=json.dumps(
            [' \t.Google.COM\n', 'github.com']))
        path = self._create_chrome_cookies_db()
        keep_list = load_keep_list()

        result = Cookie.delete_cookies(path, keep_list, really_delete=True)

        with contextlib.closing(sqlite3.connect(path)) as conn:
            hosts = {row[0] for row in conn.execute(
                'SELECT host_key FROM cookies')}
        self.assertEqual(hosts, {'google.com', 'github.com'})
        self.assertEqual(result['total_kept'], 2)
        self.assertEqual(keep_list, {'google.com', 'github.com'})

    def test_load_keep_list_bom(self):
        """A keep list saved with a UTF-8 BOM still loads"""
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        os.makedirs(bleachbit.options_dir, exist_ok=True)
        self.write_file(keep_path, '\ufeff["example.com"]'.encode('utf-8'))
        self.assertEqual(load_keep_list(), {'example.com'})

    def test_load_keep_list_not_a_list_of_strings(self):
        """Valid JSON that is not a list of strings must not clean everything"""
        action = self._make_cookie_action()
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        os.makedirs(bleachbit.options_dir, exist_ok=True)
        for data in ('"foo"', '{"example.com": true}', '42', 'null',
                     '["example.com", 1]', '[null]'):
            with self.subTest(data=data):
                self.write_file(keep_path, data, mode='w')
                with self.assertRaises(ValueError):
                    load_keep_list()
                self._assert_cookie_action_skipped(action)

    def test_load_keep_list_json_decode_error(self):
        """Corrupted JSON should raise JSONDecodeError, not return empty set"""
        action = self._make_cookie_action()
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        os.makedirs(bleachbit.options_dir, exist_ok=True)
        self.write_file(keep_path, '{invalid json', mode='w')
        with self.assertRaises(json.JSONDecodeError):
            load_keep_list()
        self._assert_cookie_action_skipped(action)

    def test_load_keep_list_unicode_decode_error(self):
        """UnicodeDecodeError should propagate, not return empty set"""
        action = self._make_cookie_action()
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        os.makedirs(bleachbit.options_dir, exist_ok=True)
        # Write invalid UTF-8 bytes
        with open(keep_path, 'wb') as f:
            f.write(b'\x80\x81\x82')
        with self.assertRaises(UnicodeDecodeError):
            load_keep_list()
        self._assert_cookie_action_skipped(action)

    def test_load_keep_list_permission_error(self):
        """PermissionError (a subtype of OSError) should propagate"""
        action = self._make_cookie_action()
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        os.makedirs(bleachbit.options_dir, exist_ok=True)
        self.write_file(keep_path, json.dumps(["example.com"]), mode='w')
        real_open = open

        def _mock_open(*args, **kwargs):
            if len(args) >= 1 and args[0] == keep_path:
                raise PermissionError('mock permission denied')
            return real_open(*args, **kwargs)
        with mock.patch('builtins.open', _mock_open):
            with self.assertRaises(PermissionError):
                load_keep_list()
            self._assert_cookie_action_skipped(action)

    @common.skipIfWindows
    def test_load_keep_list_chmod(self):
        """Test that chmod affects file access"""
        action = self._make_cookie_action()
        keep_path = os.path.join(
            bleachbit.options_dir, COOKIE_KEEP_LIST_FILENAME)
        os.makedirs(bleachbit.options_dir, exist_ok=True)
        self.write_file(keep_path, json.dumps(["example.com"]), mode='w')
        # Remove read permission
        os.chmod(keep_path, 0o200)  # write-only
        with self.assertRaises(PermissionError):
            load_keep_list()
        self._assert_cookie_action_skipped(action)
        os.chmod(keep_path, 0o600)  # restore read permission


if __name__ == '__main__':
    unittest.main()