| 1 | """Tests for the extended Chromium-family browser cookie support. |
| 2 | |
| 3 | Covers the three layers wired up for Brave/Edge/Vivaldi/Opera/Arc/Chromium: |
| 4 | - env.extract_browser_credentials (which browsers FROM_BROWSER selects) |
| 5 | - cookie_extract (routing browser name -> extractor) |
| 6 | - chrome_cookies (registry, profile finder, extraction) |
| 7 | """ |
| 8 | |
| 9 | import sqlite3 |
| 10 | from unittest.mock import patch |
| 11 | |
| 12 | import pytest |
| 13 | |
| 14 | from lib.env import extract_browser_credentials |
| 15 | from lib.cookie_extract import extract_cookies |
| 16 | from lib.chrome_cookies import ( |
| 17 | CHROMIUM_BROWSER_PROFILES, |
| 18 | _find_chromium_cookies_db, |
| 19 | extract_chromium_browser_cookies_macos, |
| 20 | ) |
| 21 | |
| 22 | # The Chromium-based browsers added on top of the original Chrome support. |
| 23 | NEW_CHROMIUM_BROWSERS = ["brave", "edge", "vivaldi", "opera", "arc", "chromium"] |
| 24 | ALL_AUTO_BROWSERS = ["firefox", "safari", "chrome", *NEW_CHROMIUM_BROWSERS] |
| 25 | |
| 26 | |
| 27 | def _base_config(**overrides): |
| 28 | cfg = { |
| 29 | "AUTH_TOKEN": None, |
| 30 | "CT0": None, |
| 31 | "TRUTHSOCIAL_TOKEN": None, |
| 32 | "FROM_BROWSER": None, |
| 33 | "SETUP_COMPLETE": None, |
| 34 | } |
| 35 | cfg.update(overrides) |
| 36 | return cfg |
| 37 | |
| 38 | |
| 39 | def _make_cookies_db(path, rows, db_version: int = 20) -> None: |
| 40 | """Create a minimal Chromium Cookies SQLite DB with plain (unencrypted) values.""" |
| 41 | conn = sqlite3.connect(str(path)) |
| 42 | c = conn.cursor() |
| 43 | c.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)") |
| 44 | c.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('version', ?)", (str(db_version),)) |
| 45 | c.execute( |
| 46 | "CREATE TABLE cookies (" |
| 47 | " host_key TEXT NOT NULL," |
| 48 | " name TEXT NOT NULL," |
| 49 | " value TEXT NOT NULL DEFAULT ''," |
| 50 | " encrypted_value BLOB NOT NULL DEFAULT x''" |
| 51 | ")" |
| 52 | ) |
| 53 | for host_key, name, value in rows: |
| 54 | c.execute( |
| 55 | "INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (?, ?, ?, ?)", |
| 56 | (host_key, name, value, b""), |
| 57 | ) |
| 58 | conn.commit() |
| 59 | conn.close() |
| 60 | |
| 61 | |
| 62 | def _make_encrypted_cookies_db(path, rows, db_version: int = 24) -> None: |
| 63 | """Create a Cookies DB with v10-encrypted_value rows (empty value column).""" |
| 64 | conn = sqlite3.connect(str(path)) |
| 65 | c = conn.cursor() |
| 66 | c.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)") |
| 67 | c.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('version', ?)", (str(db_version),)) |
| 68 | c.execute( |
| 69 | "CREATE TABLE cookies (" |
| 70 | " host_key TEXT NOT NULL," |
| 71 | " name TEXT NOT NULL," |
| 72 | " value TEXT NOT NULL DEFAULT ''," |
| 73 | " encrypted_value BLOB NOT NULL DEFAULT x''" |
| 74 | ")" |
| 75 | ) |
| 76 | for host_key, name, encrypted_value in rows: |
| 77 | c.execute( |
| 78 | "INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (?, ?, ?, ?)", |
| 79 | (host_key, name, "", encrypted_value), |
| 80 | ) |
| 81 | conn.commit() |
| 82 | conn.close() |
| 83 | |
| 84 | |
| 85 | # --------------------------------------------------------------------------- |
| 86 | # env.py: FROM_BROWSER selects the right browsers |
| 87 | # --------------------------------------------------------------------------- |
| 88 | |
| 89 | |
| 90 | class TestEnvBrowserSelection: |
| 91 | @pytest.mark.parametrize("browser", NEW_CHROMIUM_BROWSERS) |
| 92 | @patch("lib.cookie_extract.extract_cookies") |
| 93 | def test_explicit_chromium_browser_is_used(self, mock_extract, browser): |
| 94 | """FROM_BROWSER=<chromium browser> routes extraction to that browser.""" |
| 95 | mock_extract.return_value = {"auth_token": "tok", "ct0": "ct0val"} |
| 96 | config = _base_config(FROM_BROWSER=browser) |
| 97 | |
| 98 | result = extract_browser_credentials(config) |
| 99 | |
| 100 | assert result["AUTH_TOKEN"] == "tok" |
| 101 | assert result["CT0"] == "ct0val" |
| 102 | # Every extraction call targeted exactly the requested browser. |
| 103 | assert mock_extract.call_args_list |
| 104 | for call in mock_extract.call_args_list: |
| 105 | assert call[0][0] == browser |
| 106 | |
| 107 | @patch("lib.cookie_extract.extract_cookies") |
| 108 | def test_auto_tries_every_chromium_browser(self, mock_extract): |
| 109 | """FROM_BROWSER=auto tries Firefox/Safari plus the whole Chromium family.""" |
| 110 | mock_extract.return_value = None # force it to try them all |
| 111 | config = _base_config(FROM_BROWSER="auto") |
| 112 | |
| 113 | extract_browser_credentials(config) |
| 114 | |
| 115 | tried = {call[0][0] for call in mock_extract.call_args_list} |
| 116 | for browser in ALL_AUTO_BROWSERS: |
| 117 | assert browser in tried, f"auto should try {browser}" |
| 118 | |
| 119 | @patch("lib.cookie_extract.extract_cookies") |
| 120 | def test_default_skips_browser_cookie_reads(self, mock_extract): |
| 121 | """Default (no FROM_BROWSER) reads no local browser cookies.""" |
| 122 | mock_extract.return_value = None |
| 123 | config = _base_config() |
| 124 | |
| 125 | extract_browser_credentials(config) |
| 126 | |
| 127 | mock_extract.assert_not_called() |
| 128 | |
| 129 | |
| 130 | # --------------------------------------------------------------------------- |
| 131 | # cookie_extract.py: browser name routes to the chrome_cookies registry |
| 132 | # --------------------------------------------------------------------------- |
| 133 | |
| 134 | |
| 135 | class TestCookieExtractRouting: |
| 136 | @pytest.mark.parametrize("browser", ["edge", "vivaldi", "opera", "arc", "chromium"]) |
| 137 | def test_routes_to_registry(self, browser): |
| 138 | with ( |
| 139 | patch("lib.cookie_extract.platform.system", return_value="Darwin"), |
| 140 | patch( |
| 141 | "lib.chrome_cookies.extract_chromium_browser_cookies_macos", |
| 142 | return_value={"auth_token": f"{browser}_tok"}, |
| 143 | ) as mock_macos, |
| 144 | ): |
| 145 | result = extract_cookies(browser, ".x.com", ["auth_token"]) |
| 146 | |
| 147 | assert result == {"auth_token": f"{browser}_tok"} |
| 148 | # The browser key is threaded through to the macOS extractor. |
| 149 | assert mock_macos.call_args[0][0] == browser |
| 150 | |
| 151 | @pytest.mark.parametrize("browser", ["edge", "vivaldi", "opera", "arc", "chromium"]) |
| 152 | def test_non_macos_returns_none(self, browser): |
| 153 | with patch("lib.cookie_extract.platform.system", return_value="Linux"): |
| 154 | assert extract_cookies(browser, ".x.com", ["auth_token"]) is None |
| 155 | |
| 156 | def test_auto_macos_order_includes_chromium_family(self): |
| 157 | """auto on macOS calls every Chromium-family extractor when all miss.""" |
| 158 | with ( |
| 159 | patch("lib.cookie_extract.platform.system", return_value="Darwin"), |
| 160 | patch("lib.cookie_extract._extract_firefox_with_source", return_value=None), |
| 161 | patch("lib.cookie_extract.extract_chrome_cookies", return_value=None) as m_chrome, |
| 162 | patch("lib.cookie_extract.extract_brave_cookies", return_value=None) as m_brave, |
| 163 | patch("lib.cookie_extract.extract_edge_cookies", return_value=None) as m_edge, |
| 164 | patch("lib.cookie_extract.extract_vivaldi_cookies", return_value=None) as m_viv, |
| 165 | patch("lib.cookie_extract.extract_opera_cookies", return_value=None) as m_opera, |
| 166 | patch("lib.cookie_extract.extract_arc_cookies", return_value=None) as m_arc, |
| 167 | patch("lib.cookie_extract.extract_chromium_cookies", return_value=None) as m_chr, |
| 168 | patch("lib.cookie_extract.extract_safari_cookies", return_value=None), |
| 169 | ): |
| 170 | result = extract_cookies("auto", ".x.com", ["auth_token"]) |
| 171 | |
| 172 | assert result is None |
| 173 | for mock_fn in (m_chrome, m_brave, m_edge, m_viv, m_opera, m_arc, m_chr): |
| 174 | mock_fn.assert_called_once_with(".x.com", ["auth_token"]) |
| 175 | |
| 176 | |
| 177 | # --------------------------------------------------------------------------- |
| 178 | # chrome_cookies.py: registry, profile finder, generic extraction |
| 179 | # --------------------------------------------------------------------------- |
| 180 | |
| 181 | |
| 182 | class TestChromiumRegistry: |
| 183 | def test_registry_has_expected_browsers(self): |
| 184 | assert set(CHROMIUM_BROWSER_PROFILES) == {"edge", "vivaldi", "opera", "arc", "chromium"} |
| 185 | for base_dir, service in CHROMIUM_BROWSER_PROFILES.values(): |
| 186 | assert service.endswith("Safe Storage") |
| 187 | assert base_dir is not None |
| 188 | |
| 189 | def test_unknown_browser_returns_none(self): |
| 190 | assert extract_chromium_browser_cookies_macos("netscape", ".x.com", ["auth_token"]) is None |
| 191 | |
| 192 | def test_generic_extraction_plain_values(self, tmp_path): |
| 193 | """A registry browser extracts unencrypted cookies via the shared core.""" |
| 194 | base = tmp_path / "Edge" |
| 195 | (base / "Default").mkdir(parents=True) |
| 196 | _make_cookies_db( |
| 197 | base / "Default" / "Cookies", |
| 198 | [ |
| 199 | (".x.com", "auth_token", "edge_auth"), |
| 200 | (".x.com", "ct0", "edge_ct0"), |
| 201 | (".other.com", "session", "nope"), |
| 202 | ], |
| 203 | ) |
| 204 | |
| 205 | with ( |
| 206 | patch.dict( |
| 207 | "lib.chrome_cookies.CHROMIUM_BROWSER_PROFILES", |
| 208 | {"edge": (base, "Microsoft Edge Safe Storage")}, |
| 209 | ), |
| 210 | # Plain values need no Keychain; ensure we never prompt. |
| 211 | patch("lib.chrome_cookies._get_chromium_encryption_key", return_value=None), |
| 212 | ): |
| 213 | result = extract_chromium_browser_cookies_macos("edge", ".x.com", ["auth_token", "ct0"]) |
| 214 | |
| 215 | assert result == {"auth_token": "edge_auth", "ct0": "edge_ct0"} |
| 216 | |
| 217 | def test_db_not_found_returns_none(self, tmp_path): |
| 218 | empty = tmp_path / "Vivaldi" |
| 219 | empty.mkdir() |
| 220 | with patch.dict( |
| 221 | "lib.chrome_cookies.CHROMIUM_BROWSER_PROFILES", |
| 222 | {"vivaldi": (empty, "Vivaldi Safe Storage")}, |
| 223 | ): |
| 224 | assert extract_chromium_browser_cookies_macos("vivaldi", ".x.com", ["auth_token"]) is None |
| 225 | |
| 226 | |
| 227 | class TestFindChromiumCookiesDb: |
| 228 | def test_prefers_default_profile(self, tmp_path): |
| 229 | (tmp_path / "Default").mkdir() |
| 230 | default_db = tmp_path / "Default" / "Cookies" |
| 231 | default_db.touch() |
| 232 | (tmp_path / "Cookies").touch() # direct file should be ignored |
| 233 | assert _find_chromium_cookies_db(tmp_path) == default_db |
| 234 | |
| 235 | def test_falls_back_to_direct_cookies(self, tmp_path): |
| 236 | """Opera-style layout: Cookies directly under the base dir.""" |
| 237 | direct = tmp_path / "Cookies" |
| 238 | direct.touch() |
| 239 | assert _find_chromium_cookies_db(tmp_path) == direct |
| 240 | |
| 241 | def test_falls_back_to_numbered_profile(self, tmp_path): |
| 242 | prof = tmp_path / "Profile 2" |
| 243 | prof.mkdir() |
| 244 | db = prof / "Cookies" |
| 245 | db.touch() |
| 246 | assert _find_chromium_cookies_db(tmp_path) == db |
| 247 | |
| 248 | def test_returns_none_when_missing(self, tmp_path): |
| 249 | assert _find_chromium_cookies_db(tmp_path) is None |
| 250 | |
| 251 | def test_prefers_network_cookies_over_flat(self, tmp_path): |
| 252 | """Modern Chromium (>=96) stores under Default/Network/Cookies.""" |
| 253 | (tmp_path / "Default" / "Network").mkdir(parents=True) |
| 254 | net = tmp_path / "Default" / "Network" / "Cookies" |
| 255 | net.touch() |
| 256 | (tmp_path / "Default" / "Cookies").touch() # legacy flat also present |
| 257 | assert _find_chromium_cookies_db(tmp_path) == net |
| 258 | |
| 259 | def test_network_cookies_in_numbered_profile(self, tmp_path): |
| 260 | (tmp_path / "Profile 1" / "Network").mkdir(parents=True) |
| 261 | net = tmp_path / "Profile 1" / "Network" / "Cookies" |
| 262 | net.touch() |
| 263 | assert _find_chromium_cookies_db(tmp_path) == net |
| 264 | |
| 265 | |
| 266 | class TestLazyKeychain: |
| 267 | """The Keychain key is fetched only when an encrypted cookie must be decrypted. |
| 268 | |
| 269 | This keeps FROM_BROWSER=auto from prompting for every installed Chromium |
| 270 | browser - only the one actually holding the requested cookie prompts. |
| 271 | """ |
| 272 | |
| 273 | def _edge_at(self, tmp_path, rows, encrypted=False): |
| 274 | base = tmp_path / "Edge" |
| 275 | (base / "Default").mkdir(parents=True) |
| 276 | db = base / "Default" / "Cookies" |
| 277 | if encrypted: |
| 278 | _make_encrypted_cookies_db(db, rows) |
| 279 | else: |
| 280 | _make_cookies_db(db, rows) |
| 281 | return base |
| 282 | |
| 283 | def test_keychain_not_fetched_for_plain_values(self, tmp_path): |
| 284 | base = self._edge_at(tmp_path, [(".x.com", "auth_token", "plain_tok")]) |
| 285 | with ( |
| 286 | patch.dict("lib.chrome_cookies.CHROMIUM_BROWSER_PROFILES", |
| 287 | {"edge": (base, "Microsoft Edge Safe Storage")}), |
| 288 | patch("lib.chrome_cookies._get_chromium_encryption_key") as key_mock, |
| 289 | ): |
| 290 | result = extract_chromium_browser_cookies_macos("edge", ".x.com", ["auth_token"]) |
| 291 | assert result == {"auth_token": "plain_tok"} |
| 292 | key_mock.assert_not_called() # no decryption needed -> no Keychain prompt |
| 293 | |
| 294 | def test_keychain_not_fetched_when_no_match(self, tmp_path): |
| 295 | base = self._edge_at(tmp_path, [(".other.com", "auth_token", "x")]) |
| 296 | with ( |
| 297 | patch.dict("lib.chrome_cookies.CHROMIUM_BROWSER_PROFILES", |
| 298 | {"edge": (base, "Microsoft Edge Safe Storage")}), |
| 299 | patch("lib.chrome_cookies._get_chromium_encryption_key") as key_mock, |
| 300 | ): |
| 301 | result = extract_chromium_browser_cookies_macos("edge", ".x.com", ["auth_token"]) |
| 302 | assert result is None |
| 303 | key_mock.assert_not_called() # cookie absent -> no Keychain prompt |
| 304 | |
| 305 | def test_keychain_fetched_and_decrypts_v10(self, tmp_path): |
| 306 | base = self._edge_at(tmp_path, [(".x.com", "auth_token", b"v10ciphertextbytes")], encrypted=True) |
| 307 | with ( |
| 308 | patch.dict("lib.chrome_cookies.CHROMIUM_BROWSER_PROFILES", |
| 309 | {"edge": (base, "Microsoft Edge Safe Storage")}), |
| 310 | patch("lib.chrome_cookies._get_chromium_encryption_key", return_value=b"passphrase") as key_mock, |
| 311 | patch("lib.chrome_cookies._decrypt_v10_value", return_value="decrypted_tok") as dec_mock, |
| 312 | ): |
| 313 | result = extract_chromium_browser_cookies_macos("edge", ".x.com", ["auth_token"]) |
| 314 | assert result == {"auth_token": "decrypted_tok"} |
| 315 | key_mock.assert_called_once_with("Microsoft Edge Safe Storage") |
| 316 | assert dec_mock.called |
| 317 |